|
1 | 1 | use async_trait::async_trait; |
| 2 | +use axum::body::Bytes; |
2 | 3 | use axum::http::{HeaderMap, HeaderValue, header}; |
3 | 4 | use serde_json::Value; |
4 | 5 | use std::sync::Arc; |
@@ -47,6 +48,15 @@ impl<M> ModelApiProvider<M> for ResponsesClient { |
47 | 48 | _metadata: &M, |
48 | 49 | ) -> Result<ProviderResponse, anyhow::Error> { |
49 | 50 | req.endpoint_path = "/responses".to_string(); |
| 51 | + if req |
| 52 | + .content_type |
| 53 | + .as_deref() |
| 54 | + .is_some_and(|content_type| content_type.starts_with("application/json")) |
| 55 | + { |
| 56 | + if let Some(sanitized_body) = sanitize_responses_request_body(&req.body) { |
| 57 | + req.body = sanitized_body; |
| 58 | + } |
| 59 | + } |
50 | 60 | proxy_request( |
51 | 61 | &self.client, |
52 | 62 | &self.base_url, |
@@ -113,9 +123,54 @@ fn non_null_usage(value: Option<&Value>) -> Option<Value> { |
113 | 123 | value.filter(|usage| !usage.is_null()).cloned() |
114 | 124 | } |
115 | 125 |
|
| 126 | +fn sanitize_responses_request_body(body: &Bytes) -> Option<Bytes> { |
| 127 | + let mut payload_json: Value = serde_json::from_slice(body).ok()?; |
| 128 | + let payload_obj = payload_json.as_object_mut()?; |
| 129 | + let model = payload_obj.get("model").and_then(Value::as_str)?; |
| 130 | + if !matches!(model, "gpt-5.6-sol" | "gpt-5.6-luna" | "gpt-5.6-terra") { |
| 131 | + return None; |
| 132 | + } |
| 133 | + |
| 134 | + let input_items = payload_obj.get_mut("input")?.as_array_mut()?; |
| 135 | + let mut changed = false; |
| 136 | + |
| 137 | + for item in input_items { |
| 138 | + let Some(item_obj) = item.as_object_mut() else { |
| 139 | + continue; |
| 140 | + }; |
| 141 | + |
| 142 | + let missing_or_empty_type = match item_obj.get("type") { |
| 143 | + None => true, |
| 144 | + Some(Value::String(value)) => value.trim().is_empty(), |
| 145 | + Some(Value::Null) => true, |
| 146 | + _ => false, |
| 147 | + }; |
| 148 | + if !missing_or_empty_type { |
| 149 | + continue; |
| 150 | + } |
| 151 | + |
| 152 | + let has_role = item_obj.get("role").and_then(Value::as_str).is_some(); |
| 153 | + let has_content = item_obj.contains_key("content"); |
| 154 | + if has_role && has_content { |
| 155 | + item_obj.insert("type".to_string(), Value::String("message".to_string())); |
| 156 | + changed = true; |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + if !changed { |
| 161 | + return None; |
| 162 | + } |
| 163 | + |
| 164 | + serde_json::to_vec(&payload_json).ok().map(Bytes::from) |
| 165 | +} |
| 166 | + |
116 | 167 | #[cfg(test)] |
117 | 168 | mod tests { |
118 | | - use super::{extract_request_id_json, extract_usage_json, inject_usage_json}; |
| 169 | + use super::{ |
| 170 | + extract_request_id_json, extract_usage_json, inject_usage_json, |
| 171 | + sanitize_responses_request_body, |
| 172 | + }; |
| 173 | + use axum::body::Bytes; |
119 | 174 | use serde_json::json; |
120 | 175 |
|
121 | 176 | /// Verifies full payload request id extraction prefers top-level `id`. |
@@ -180,6 +235,131 @@ mod tests { |
180 | 235 | assert!(injected); |
181 | 236 | assert_eq!(payload.get("usage"), Some(&new_usage)); |
182 | 237 | } |
| 238 | + |
| 239 | + /// Verifies message-like input items get `type: message` for targeted GPT-5.6 models. |
| 240 | + #[test] |
| 241 | + fn sanitize_responses_request_body_adds_message_type_for_target_models() { |
| 242 | + let body = Bytes::from( |
| 243 | + serde_json::to_vec(&json!({ |
| 244 | + "model": "gpt-5.6-sol", |
| 245 | + "input": [ |
| 246 | + { |
| 247 | + "role": "user", |
| 248 | + "content": [{"type": "input_text", "text": "hi"}] |
| 249 | + } |
| 250 | + ] |
| 251 | + })) |
| 252 | + .expect("serialize test body"), |
| 253 | + ); |
| 254 | + |
| 255 | + let sanitized = |
| 256 | + sanitize_responses_request_body(&body).expect("expected request body to be sanitized"); |
| 257 | + let sanitized_json: serde_json::Value = |
| 258 | + serde_json::from_slice(&sanitized).expect("parse sanitized request body"); |
| 259 | + |
| 260 | + assert_eq!(sanitized_json["input"][0]["type"], "message"); |
| 261 | + assert_eq!(sanitized_json["input"][0]["content"][0]["text"], "hi"); |
| 262 | + } |
| 263 | + |
| 264 | + /// Verifies non-target models are left untouched. |
| 265 | + #[test] |
| 266 | + fn sanitize_responses_request_body_skips_non_target_models() { |
| 267 | + let body = Bytes::from( |
| 268 | + serde_json::to_vec(&json!({ |
| 269 | + "model": "gpt-5.4", |
| 270 | + "input": [ |
| 271 | + { |
| 272 | + "role": "user", |
| 273 | + "content": [{"type": "input_text", "text": "hi"}] |
| 274 | + } |
| 275 | + ] |
| 276 | + })) |
| 277 | + .expect("serialize test body"), |
| 278 | + ); |
| 279 | + |
| 280 | + let sanitized = sanitize_responses_request_body(&body); |
| 281 | + |
| 282 | + assert!(sanitized.is_none()); |
| 283 | + } |
| 284 | + |
| 285 | + /// Verifies non-message items are preserved while compatible message items are repaired. |
| 286 | + #[test] |
| 287 | + fn sanitize_responses_request_body_preserves_non_message_items() { |
| 288 | + let body = Bytes::from( |
| 289 | + serde_json::to_vec(&json!({ |
| 290 | + "model": "gpt-5.6-terra", |
| 291 | + "input": [ |
| 292 | + { |
| 293 | + "type": "", |
| 294 | + "foo": "bar" |
| 295 | + }, |
| 296 | + { |
| 297 | + "type": "", |
| 298 | + "role": "user", |
| 299 | + "content": [{"type": "input_text", "text": "hello"}] |
| 300 | + } |
| 301 | + ] |
| 302 | + })) |
| 303 | + .expect("serialize test body"), |
| 304 | + ); |
| 305 | + |
| 306 | + let sanitized = |
| 307 | + sanitize_responses_request_body(&body).expect("expected request body to be sanitized"); |
| 308 | + let sanitized_json: serde_json::Value = |
| 309 | + serde_json::from_slice(&sanitized).expect("parse sanitized request body"); |
| 310 | + |
| 311 | + assert_eq!(sanitized_json["input"].as_array().map(Vec::len), Some(2)); |
| 312 | + assert_eq!(sanitized_json["input"][0]["type"], ""); |
| 313 | + assert_eq!(sanitized_json["input"][0]["foo"], "bar"); |
| 314 | + assert_eq!(sanitized_json["input"][1]["type"], "message"); |
| 315 | + assert_eq!(sanitized_json["input"][1]["content"][0]["text"], "hello"); |
| 316 | + } |
| 317 | + |
| 318 | + /// Verifies all message-shaped roles are repaired without dropping other inputs. |
| 319 | + #[test] |
| 320 | + fn sanitize_responses_request_body_repairs_all_message_roles() { |
| 321 | + let body = Bytes::from( |
| 322 | + serde_json::to_vec(&json!({ |
| 323 | + "model": "gpt-5.6-luna", |
| 324 | + "input": [ |
| 325 | + { |
| 326 | + "role": "developer", |
| 327 | + "content": "system rules" |
| 328 | + }, |
| 329 | + { |
| 330 | + "type": null, |
| 331 | + "role": "assistant", |
| 332 | + "id": "msg_1", |
| 333 | + "phase": "loop", |
| 334 | + "content": [{"type": "output_text", "text": "ok"}] |
| 335 | + }, |
| 336 | + { |
| 337 | + "type": " ", |
| 338 | + "role": "user", |
| 339 | + "content": [{"type": "input_text", "text": "hi"}] |
| 340 | + }, |
| 341 | + { |
| 342 | + "type": "reasoning", |
| 343 | + "id": "rs_1", |
| 344 | + "encrypted_content": "abc" |
| 345 | + } |
| 346 | + ] |
| 347 | + })) |
| 348 | + .expect("serialize test body"), |
| 349 | + ); |
| 350 | + |
| 351 | + let sanitized = |
| 352 | + sanitize_responses_request_body(&body).expect("expected request body to be sanitized"); |
| 353 | + let sanitized_json: serde_json::Value = |
| 354 | + serde_json::from_slice(&sanitized).expect("parse sanitized request body"); |
| 355 | + |
| 356 | + assert_eq!(sanitized_json["input"][0]["type"], "message"); |
| 357 | + assert_eq!(sanitized_json["input"][1]["type"], "message"); |
| 358 | + assert_eq!(sanitized_json["input"][2]["type"], "message"); |
| 359 | + assert_eq!(sanitized_json["input"][1]["id"], "msg_1"); |
| 360 | + assert_eq!(sanitized_json["input"][1]["phase"], "loop"); |
| 361 | + assert_eq!(sanitized_json["input"][3]["type"], "reasoning"); |
| 362 | + } |
183 | 363 | } |
184 | 364 |
|
185 | 365 | pub fn build_client<M>( |
|
0 commit comments