Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
31 changes: 28 additions & 3 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,14 @@ 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.
normalize_system_input_roles(&mut body);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Ok(EncodedRequest {
body,
diagnostics: Vec::new(),
Expand Down Expand Up @@ -1126,11 +1131,31 @@ 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()));
}
}
}

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
99 changes: 99 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,101 @@ 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(())
}