Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 54 additions & 11 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,17 @@ impl FormatCodec for OpenAiResponsesCodec {
request: &LlmRequest,
_policy: &TranslationPolicy,
) -> Result<EncodedRequest> {
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
normalize_input_to_message_list(&mut body);
return Ok(EncodedRequest {
body,
diagnostics: Vec::new(),
Expand Down Expand Up @@ -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<TranslationDiagnostic>,
policy: &TranslationPolicy,
namespaces: Option<&Map<String, Value>>,
) -> Result<Value> {
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.
Expand Down Expand Up @@ -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",
}
}
Expand Down
29 changes: 29 additions & 0 deletions crates/switchyard-translation/tests/lossless_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
186 changes: 186 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use switchyard_translation::{
};

use common::{REASONING_MODEL, normalized_policy, shell_tool_call};
use switchyard_translation::PreservationPolicy;

type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;

Expand Down Expand Up @@ -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(())
}