Skip to content

Commit ca23585

Browse files
authored
fix: responses schema normalization (#1255)
1 parent 63c7020 commit ca23585

1 file changed

Lines changed: 181 additions & 1 deletion

File tree

src/model_api_provider/providers/responses.rs

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use async_trait::async_trait;
2+
use axum::body::Bytes;
23
use axum::http::{HeaderMap, HeaderValue, header};
34
use serde_json::Value;
45
use std::sync::Arc;
@@ -47,6 +48,15 @@ impl<M> ModelApiProvider<M> for ResponsesClient {
4748
_metadata: &M,
4849
) -> Result<ProviderResponse, anyhow::Error> {
4950
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+
}
5060
proxy_request(
5161
&self.client,
5262
&self.base_url,
@@ -113,9 +123,54 @@ fn non_null_usage(value: Option<&Value>) -> Option<Value> {
113123
value.filter(|usage| !usage.is_null()).cloned()
114124
}
115125

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+
116167
#[cfg(test)]
117168
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;
119174
use serde_json::json;
120175

121176
/// Verifies full payload request id extraction prefers top-level `id`.
@@ -180,6 +235,131 @@ mod tests {
180235
assert!(injected);
181236
assert_eq!(payload.get("usage"), Some(&new_usage));
182237
}
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+
}
183363
}
184364

185365
pub fn build_client<M>(

0 commit comments

Comments
 (0)