diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 03e11f15b..fe435823c 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -122,9 +122,17 @@ impl FormatCodec for OpenAiResponsesCodec { request: &LlmRequest, _policy: &TranslationPolicy, ) -> Result { - if let Some(body) = + if let Some(mut body) = exact_preserved_request(&request.preservation, WireFormat::OpenAiResponses, _policy) { + // Strict Responses backends reject `system`-role input items ("System + // messages are not allowed"); they require `developer`. The preserved + // body replays verbatim, so normalize it here to keep exact-request + // passthrough wire-legal. A scalar string `input` is likewise + // converted to the canonical message-item list so preserved bodies + // keep the always-list wire shape the encode path guarantees. + normalize_system_input_roles(&mut body); + normalize_input_to_message_list(&mut body); return Ok(EncodedRequest { body, diagnostics: Vec::new(), @@ -1011,20 +1019,17 @@ fn encode_responses_text_format(response_format: &Value) -> Value { } // Encodes normalized messages into the Responses `input` shape. +// +// The result is ALWAYS a message-item list. Strict Responses backends +// (chatgpt.com Codex endpoint) reject the scalar string form, and list input +// is universally accepted by normal `/v1/responses` endpoints, so the +// historical single-user-text scalar fast path is intentionally gone. fn encode_responses_input( messages: &[Message], diagnostics: &mut Vec, policy: &TranslationPolicy, namespaces: Option<&Map>, ) -> Result { - if messages.len() == 1 - && matches!(messages[0].role, Role::User) - && messages[0].content.len() == 1 - && matches!(messages[0].content[0], ContentBlock::Text { .. }) - && let ContentBlock::Text { text } = &messages[0].content[0] - { - return Ok(Value::String(text.clone())); - } let mut encoded = Vec::new(); for message in messages { // Anthropic-signed thinking cannot be sent as Responses input. @@ -1126,11 +1131,49 @@ fn encode_responses_special_input( } // Maps normalized roles back to Responses role strings. +// Rewrites `system`-role items in a raw Responses `input` array to `developer`. +// Strict Responses backends (chatgpt.com Codex endpoint) reject `system`-role +// input items ("System messages are not allowed"); they require `developer`. +// Handles both typed items ({"type":"message","role":"system"}) and untyped +// role-keyed items. +fn normalize_system_input_roles(body: &mut Value) { + let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else { + return; + }; + for item in input.iter_mut() { + if item.get("role").and_then(Value::as_str) == Some("system") + && let Some(obj) = item.as_object_mut() + { + obj.insert("role".to_string(), Value::String("developer".to_string())); + } + } +} + +// Converts a scalar string `input` into the canonical single user message-item +// list. Preserved bodies replay verbatim, so without this a preserved request +// carrying `"input": "hi"` would bypass the always-list shape the encoder +// guarantees (strict Codex backends reject the scalar form). +fn normalize_input_to_message_list(body: &mut Value) { + let Some(Value::String(text)) = body.get("input") else { + return; + }; + let item = json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text.clone()}], + }); + if let Some(obj) = body.as_object_mut() { + obj.insert("input".to_string(), Value::Array(vec![item])); + } +} + fn role_to_responses(role: Role) -> &'static str { match role { Role::Assistant => "assistant", - Role::System => "system", - Role::Developer => "developer", + // Strict Responses backends reject `system`-role input items; they + // require `developer` (mirrors the codex wire rules for chat -> + // Responses conversions). + Role::System | Role::Developer => "developer", Role::User | Role::Tool => "user", } } diff --git a/crates/switchyard-translation/tests/lossless_roundtrip.rs b/crates/switchyard-translation/tests/lossless_roundtrip.rs index c9375b481..7d5a117df 100644 --- a/crates/switchyard-translation/tests/lossless_roundtrip.rs +++ b/crates/switchyard-translation/tests/lossless_roundtrip.rs @@ -236,6 +236,35 @@ fn in_memory_preservation_replays_exact_original_when_encoding_from_the_same_ir( let decoded = engine.decode_request(WireFormat::OpenAiResponses, &original, &policy)?; let encoded = engine.encode_request(WireFormat::OpenAiResponses, &decoded.request, &policy)?; + // Preserved replay is exact except for the always-list wire normalization: + // the scalar string `input` is converted to the canonical single user + // message-item list (strict Responses backends reject the scalar form). + let mut expected = original.clone(); + expected["input"] = json!([{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}], + }]); + assert_eq!(encoded.body, expected); + Ok(()) +} + +// An already-canonical list `input` replays byte-exact: the preserved-path +// normalizations only touch wire-illegal shapes (scalar input, system roles). +#[test] +fn in_memory_preservation_replays_exact_original_with_list_input() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy::default(); + let original = json!({ + "model": "gpt-4o", + "input": [{"type": "message", "role": "user", "content": "Hello"}], + "metadata": {"trace": "keep-me"}, + "store": false + }); + + let decoded = engine.decode_request(WireFormat::OpenAiResponses, &original, &policy)?; + let encoded = engine.encode_request(WireFormat::OpenAiResponses, &decoded.request, &policy)?; + assert_eq!(encoded.body, original); Ok(()) } diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index d27b21cd9..861d8dcb5 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -13,6 +13,7 @@ use switchyard_translation::{ }; use common::{REASONING_MODEL, normalized_policy, shell_tool_call}; +use switchyard_translation::PreservationPolicy; type TestResult = std::result::Result>; @@ -2389,3 +2390,188 @@ fn responses_flat_file_data_survives_into_chat() -> TestResult { assert_eq!(file["file"]["filename"], "report.pdf"); Ok(()) } + +// Strict Codex Responses backends reject `system`-role input items ("System +// messages are not allowed"); they require `developer`. Typed items +// ({"type":"message","role":"system"}) and untyped role-keyed items alike must +// normalize to `developer` in the raw body BEFORE preservation capture, so +// exact-request passthrough stays wire-legal too. +#[test] +fn responses_system_role_items_normalize_to_developer_typed_and_untyped() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5.6-luna", + "input": [ + {"type": "message", "role": "system", "content": [ + {"type": "input_text", "text": "typed system"} + ]}, + {"role": "system", "content": [ + {"type": "input_text", "text": "untyped system"} + ]}, + {"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "hi"} + ]} + ], + "stream": true + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .body; + + let input = output["input"] + .as_array() + .ok_or("Responses input should remain an array")?; + assert_eq!( + input[0]["role"], "developer", + "typed system item must normalize to developer" + ); + assert_eq!( + input[1]["role"], "developer", + "untyped system item must normalize to developer" + ); + assert_eq!(input[2]["role"], "user", "user item must be untouched"); + let serialized = serde_json::to_string(&output)?; + assert!( + !serialized.contains("\"role\":\"system\"") && !serialized.contains("\"role\": \"system\""), + "no system role may survive on the Responses wire" + ); + Ok(()) +} + +// Embed-preservation replay must also stay wire-legal: preservation metadata is +// embedded in the translated body, but no `system`-role input item may survive +// into the outgoing input array. +#[test] +fn responses_embed_preservation_replay_has_no_system_role_items() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Embed, + ..TranslationPolicy::default() + }; + let body = json!({ + "model": "gpt-5.6-luna", + "input": [ + {"type": "message", "role": "system", "content": "Be terse."}, + {"type": "message", "role": "user", "content": "hi"} + ], + "stream": true + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &policy, + )? + .body; + + if let Some(input) = output["input"].as_array() { + for item in input { + assert_ne!( + item.get("role").and_then(Value::as_str), + Some("system"), + "embed-preservation replay must not re-emit system-role items" + ); + } + } + // Same-format requests under the Embed policy replay the exact preserved + // body (cross-format hops carry the metadata envelope instead); the replay + // must be a canonical input array with no `system`-role items. + assert!( + output["input"].is_array(), + "replay must keep a canonical input array" + ); + Ok(()) +} + +// The Responses encoder must never emit the scalar string `input` shape: +// single-user-text requests encode as the canonical message-item list (strict +// Codex backends reject the scalar form; list input is universally accepted by +// normal /v1/responses endpoints). CodeRabbit finding on PR #619. +#[test] +fn responses_encode_never_emits_scalar_string_input() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-4o", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + }); + + let output = engine + .translate_request( + WireFormat::OpenAiChat, + WireFormat::OpenAiResponses, + &body, + &TranslationPolicy::default(), + )? + .body; + + let input = output["input"] + .as_array() + .expect("encoded input must be a message-item list, never a scalar string"); + assert_eq!(input.len(), 1, "single user text becomes one message item"); + assert_eq!( + input[0].get("type").and_then(Value::as_str), + Some("message") + ); + assert_eq!(input[0].get("role").and_then(Value::as_str), Some("user")); + Ok(()) +} + +// Preserved replay must also stay wire-legal for scalar input: a preserved +// body carrying the scalar string `input` would otherwise bypass the +// always-list guarantee, so the replay normalizes it to the canonical single +// user message-item list. CodeRabbit finding on PR #619. +#[test] +fn responses_preserved_scalar_input_replays_as_message_list() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Embed, + ..TranslationPolicy::default() + }; + let body = json!({ + "model": "gpt-5.6-luna", + "input": "hi", + "stream": true + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &policy, + )? + .body; + + let input = output["input"] + .as_array() + .expect("preserved replay must keep a canonical input list"); + assert_eq!( + input.len(), + 1, + "scalar input becomes exactly one message item" + ); + assert_eq!( + input[0].get("type").and_then(Value::as_str), + Some("message") + ); + assert_eq!(input[0].get("role").and_then(Value::as_str), Some("user")); + assert_eq!( + input[0].pointer("/content/0/type").and_then(Value::as_str), + Some("input_text") + ); + assert_eq!( + input[0].pointer("/content/0/text").and_then(Value::as_str), + Some("hi"), + "scalar input text must be preserved verbatim" + ); + Ok(()) +}