Skip to content
Merged
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
103 changes: 91 additions & 12 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

//! Buffered codec for OpenAI Responses request and response JSON.

use std::collections::HashSet;
use std::collections::{HashMap, HashSet};

use serde_json::{Map, Value, json};

Expand Down Expand Up @@ -40,6 +40,11 @@ impl FormatCodec for OpenAiResponsesCodec {

fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
let body = crate::util::object(body, "$")?;
// Codex marks remote-compact requests with a `compaction_trigger` input
// item. It is codex-internal protocol that strict upstream parsers
// reject, so drop it before preservation capture and normalization.
let sanitized = strip_codex_compaction_markers(body);
let body = sanitized.as_ref().unwrap_or(body);
let mut diagnostics = Vec::new();
let mut request = LlmRequest {
model: body
Expand Down Expand Up @@ -1008,6 +1013,16 @@ fn encode_responses_input(
return Ok(Value::String(text.clone()));
}
let mut encoded = Vec::new();
// Some upstream translators (Kimi K3) resolve a tool output by an explicit
// `name`, so pair every output with the name of the call it answers.
let mut call_names: HashMap<&str, &str> = HashMap::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolCall(call) = block {
call_names.insert(call.id.as_str(), call.name.as_str());
}
}
}
for message in messages {
// Anthropic-signed thinking cannot be sent as Responses input.
let content = message
Expand All @@ -1033,18 +1048,16 @@ fn encode_responses_input(
ContentBlock::ToolCall(_) | ContentBlock::ToolResult(_)
)
}) {
encoded.extend(
content
.iter()
.filter_map(|block| encode_responses_special_input(block, namespaces)),
);
encoded.extend(content.iter().filter_map(|block| {
encode_responses_special_input(block, namespaces, &call_names)
}));
continue;
}
let mut visible_content = Vec::new();
let mut emitted_special = false;
let mut omitted_reasoning = false;
for block in &content {
if let Some(item) = encode_responses_special_input(block, namespaces) {
if let Some(item) = encode_responses_special_input(block, namespaces, &call_names) {
encoded.push(item);
emitted_special = true;
} else if !matches!(block, ContentBlock::Reasoning { .. }) {
Expand All @@ -1062,13 +1075,66 @@ fn encode_responses_input(
}));
}
}
pair_tool_calls_with_outputs(&mut encoded);
Ok(Value::Array(encoded))
}

// Moves each tool output directly behind the call it answers. A turn with
// parallel tool calls otherwise serializes as call,call,output,output, which
// upstreams that pair by adjacency (Kimi K3) resolve to the wrong call.
fn pair_tool_calls_with_outputs(items: &mut Vec<Value>) {
let call_id = |item: &Value, kind: &str| {
(item.get("type").and_then(Value::as_str) == Some(kind))
.then(|| {
item.get("call_id")
.and_then(Value::as_str)
.map(str::to_owned)
})
.flatten()
};
let mut index = 0;
while index < items.len() {
let Some(id) = call_id(&items[index], "function_call") else {
index += 1;
continue;
};
let output = items
.iter()
.skip(index + 1)
.position(|item| call_id(item, "function_call_output").as_deref() == Some(&id))
.map(|offset| index + 1 + offset);
if let Some(output) = output
&& output != index + 1
{
let item = items.remove(output);
items.insert(index + 1, item);
}
index += 1;
}
}

// Returns the body without Codex `compaction_trigger` input items, or `None`
// when there are none (the common case, sparing the clone).
fn strip_codex_compaction_markers(body: &Map<String, Value>) -> Option<Map<String, Value>> {
fn is_marker(item: &Value) -> bool {
item.get("type").and_then(Value::as_str) == Some("compaction_trigger")
}
let input = body.get("input")?.as_array()?;
if !input.iter().any(is_marker) {
return None;
}
let mut sanitized = body.clone();
if let Some(Value::Array(items)) = sanitized.get_mut("input") {
items.retain(|item| !is_marker(item));
}
Some(sanitized)
}

// Encodes IR blocks that Responses represents as top-level input items.
fn encode_responses_special_input(
block: &ContentBlock,
namespaces: Option<&Map<String, Value>>,
call_names: &HashMap<&str, &str>,
) -> Option<Value> {
match block {
ContentBlock::Reasoning {
Expand Down Expand Up @@ -1097,11 +1163,24 @@ fn encode_responses_special_input(
}
Some(item)
}
ContentBlock::ToolResult(result) => Some(json!({
"type": "function_call_output",
"call_id": result.tool_call_id,
"output": text_from_blocks(&result.content, " "),
})),
ContentBlock::ToolResult(result) => {
let mut item = json!({
"type": "function_call_output",
"call_id": result.tool_call_id,
"output": text_from_blocks(&result.content, " "),
});
// Carry the paired call's name, un-qualified to match the emitted
// function_call, for upstreams that resolve outputs by name.
if let Some(name) = call_names.get(result.tool_call_id.as_str()) {
let name = namespaces
.and_then(|namespaces| {
crate::codex_namespaces::split_qualified_name(namespaces, name)
})
.map_or_else(|| (*name).to_string(), |(name, _)| name);
item["name"] = Value::String(name);
}
Some(item)
}
_ => None,
}
}
Expand Down
89 changes: 89 additions & 0 deletions crates/switchyard-translation/tests/request_translation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1441,6 +1441,9 @@ fn responses_reasoning_items_round_trip_through_decode_and_encode() -> TestResul
);
assert!(input[1].get("content").is_none());
assert_eq!(input[2]["call_id"], "call-ls");
// Outputs carry the paired call's name for upstreams (Kimi K3) that
// resolve tool results by name rather than by call order.
assert_eq!(input[3]["name"], "shell");
Ok(())
}

Expand Down Expand Up @@ -2484,3 +2487,89 @@ fn responses_flat_file_data_survives_into_chat() -> TestResult {
assert_eq!(file["file"]["filename"], "report.pdf");
Ok(())
}

// Verifies parallel tool calls serialize as adjacent call/output pairs so
// upstreams that resolve tool outputs by adjacency match the right call.
#[test]
fn responses_parallel_tool_calls_pair_with_their_outputs() -> TestResult {
let engine = TranslationEngine::default();
let policy = TranslationPolicy {
preservation: switchyard_translation::PreservationPolicy::Disabled,
..TranslationPolicy::default()
};
let body = json!({
"model": "gpt-5",
"input": [
{"type": "message", "role": "user", "content": "Inspect"},
{"type": "function_call", "name": "shell", "call_id": "call-a", "arguments": "{}"},
{"type": "function_call", "name": "shell", "call_id": "call-b", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call-a", "output": "a"},
{"type": "function_call_output", "call_id": "call-b", "output": "b"}
]
});

let output = engine
.translate_request(
WireFormat::OpenAiResponses,
WireFormat::OpenAiResponses,
&body,
&policy,
)?
.body;

let input = output["input"].as_array().ok_or("input is not an array")?;
let pairs = input
.iter()
.filter_map(|item| Some((item["type"].as_str()?, item["call_id"].as_str()?)))
.collect::<Vec<_>>();
assert_eq!(
pairs,
vec![
("function_call", "call-a"),
("function_call_output", "call-a"),
("function_call", "call-b"),
("function_call_output", "call-b"),
]
);
Ok(())
}

// Verifies Codex `compaction_trigger` marker items never reach the upstream.
#[test]
fn responses_codex_compaction_markers_are_stripped() -> TestResult {
let engine = TranslationEngine::default();
let policy = TranslationPolicy {
preservation: switchyard_translation::PreservationPolicy::Disabled,
..TranslationPolicy::default()
};
let body = json!({
"model": "gpt-5",
"input": [
{"type": "message", "role": "user", "content": "Continue"},
{"type": "compaction_trigger"},
{"type": "function_call", "name": "shell", "call_id": "call-a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call-a", "output": "ok"}
]
});

let output = engine
.translate_request(
WireFormat::OpenAiResponses,
WireFormat::OpenAiResponses,
&body,
&policy,
)?
.body;

let types = output["input"]
.as_array()
.ok_or("input is not an array")?
.iter()
.filter_map(|item| item["type"].as_str())
.collect::<Vec<_>>();
assert_eq!(
types,
vec!["message", "function_call", "function_call_output"]
);
Ok(())
}
Loading