From 7260828c98e5f3c8dfadb9f6086864616d3604fd Mon Sep 17 00:00:00 2001 From: Lin Jia Date: Tue, 8 Sep 2026 10:58:19 -0700 Subject: [PATCH 1/3] feat(translation): round-trip Responses freeform custom tools Codex drives GPT-5 models with freeform tools: the definition is {"type": "custom", ...} and the model answers with custom_tool_call items whose input is a raw string. The Responses codec only modelled function tools, so a Codex session against a GPT-5 model through Switchyard lost its tool definitions and its tool calls and ended after one turn. Custom tools now pass through the IR as a function with a single input argument, with the verbatim definitions kept on the request extensions. History items custom_tool_call and custom_tool_call_output decode and re-encode with their types intact, upstream custom_tool_call output items decode on both the buffered and stream paths, and when a response is encoded with the request's extensions, calls to a custom tool are rewritten back into custom_tool_call items. Argument delta events for such calls are dropped on the stream because a partial JSON delta has no freeform equivalent; clients read the completed item. Signed-off-by: Lin Jia Co-Authored-By: Claude Fable 5.1 --- .../src/codecs/responses/buffered.rs | 137 ++++++++++- .../src/codecs/responses/stream.rs | 32 ++- .../src/codex_custom_tools.rs | 222 ++++++++++++++++++ crates/switchyard-translation/src/engine.rs | 4 + crates/switchyard-translation/src/helpers.rs | 23 +- crates/switchyard-translation/src/lib.rs | 1 + .../tests/request_translation.rs | 79 +++++++ .../tests/response_translation.rs | 73 ++++++ 8 files changed, 552 insertions(+), 19 deletions(-) create mode 100644 crates/switchyard-translation/src/codex_custom_tools.rs diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 10fab3684..05b40dfa1 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -91,7 +91,9 @@ impl FormatCodec for OpenAiResponsesCodec { request.messages = messages; request.instructions.extend(instructions); let mut tool_namespaces = Map::new(); - request.tools = decode_responses_tools(body.get("tools"), &mut tool_namespaces); + let mut custom_tools = Map::new(); + request.tools = + decode_responses_tools(body.get("tools"), &mut tool_namespaces, &mut custom_tools); request.tool_choice = body .get("tool_choice") .and_then(decode_responses_tool_choice); @@ -112,6 +114,7 @@ impl FormatCodec for OpenAiResponsesCodec { ], ); crate::codex_namespaces::attach_tool_namespaces(&mut request.extensions, tool_namespaces); + crate::codex_custom_tools::attach_custom_tools(&mut request.extensions, custom_tools); Ok(DecodedRequest { request, diagnostics, @@ -157,6 +160,7 @@ impl FormatCodec for OpenAiResponsesCodec { &mut diagnostics, _policy, crate::codex_namespaces::tool_namespaces(&request.extensions), + &crate::codex_custom_tools::custom_tool_names(&request.extensions), )?, ); if !request.tools.is_empty() { @@ -165,6 +169,7 @@ impl FormatCodec for OpenAiResponsesCodec { encode_responses_tools( &request.tools, crate::codex_namespaces::tool_namespaces(&request.extensions), + crate::codex_custom_tools::custom_tools(&request.extensions), ), ); } @@ -475,7 +480,7 @@ fn decode_responses_input( arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})), }); } - Some("function_call_output") => { + Some("function_call_output") | Some("custom_tool_call_output") => { let tool_call_id = item .get("call_id") .and_then(Value::as_str) @@ -488,6 +493,45 @@ fn decode_responses_input( is_error: None, }); } + Some("custom_tool_call") => { + // A freeform call carries a raw `input` string; it rides through the IR + // as the single `input` argument of a function-style call. + if !pending_tool_outputs.is_empty() { + flush_responses_tool_block( + &mut messages, + &mut pending_tool_calls, + &mut pending_tool_outputs, + &mut deferred_messages, + &mut pending_reasoning, + ); + } + let id = item + .get("call_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| match &policy.deterministic_ids { + DeterministicIdPolicy::GenerateStable { prefix } => { + stable_id(prefix, index + 1) + } + DeterministicIdPolicy::Preserve => String::new(), + }); + let name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let input = item + .get("input") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + pending_tool_calls.push(ToolCall { + id, + name, + arguments: json!({crate::codex_custom_tools::INPUT_ARGUMENT: input}), + }); + } None => { return Err(TranslationError::InvalidValue { path: format!("$.input[{index}].type"), @@ -776,6 +820,7 @@ fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { fn decode_responses_tools( value: Option<&Value>, namespaces: &mut Map, + custom_tools: &mut Map, ) -> Vec { let Some(tools) = value.and_then(Value::as_array) else { return Vec::new(); @@ -792,7 +837,7 @@ fn decode_responses_tools( .get("name") .and_then(Value::as_str) .filter(|name| !name.is_empty()); - for mut child in decode_responses_tools(tool.get("tools"), namespaces) { + for mut child in decode_responses_tools(tool.get("tools"), namespaces, custom_tools) { // A nested container already qualified its own children, and the // innermost name is the one that identifies the tool. let already_qualified = namespaces.contains_key(&child.name); @@ -808,6 +853,26 @@ fn decode_responses_tools( } out.push(child); } + } else if tool.get("type").and_then(Value::as_str) == Some("custom") { + // A freeform tool takes a raw string, not JSON arguments. The IR sees it as a + // function with a single `input` argument; the verbatim definition is kept so a + // Responses upstream still receives the freeform tool. + if let Some(name) = tool + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + { + custom_tools.insert(name.to_string(), Value::Object(tool.clone())); + out.push(ToolDefinition { + name: name.to_string(), + description: tool + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + parameters: crate::codex_custom_tools::input_schema(), + strict: None, + }); + } } else if tool.get("type").and_then(Value::as_str) == Some("function") { if let Some(function) = tool.get("function").and_then(Value::as_object) { if let Some(name) = function.get("name").and_then(Value::as_str) @@ -998,7 +1063,9 @@ fn encode_responses_input( diagnostics: &mut Vec, policy: &TranslationPolicy, namespaces: Option<&Map>, + custom_tools: &std::collections::HashSet, ) -> Result { + let mut custom_call_ids: std::collections::HashSet = std::collections::HashSet::new(); if messages.len() == 1 && matches!(messages[0].role, Role::User) && messages[0].content.len() == 1 @@ -1033,18 +1100,26 @@ 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, + custom_tools, + &mut custom_call_ids, + ) + })); 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, + custom_tools, + &mut custom_call_ids, + ) { encoded.push(item); emitted_special = true; } else if !matches!(block, ContentBlock::Reasoning { .. }) { @@ -1069,6 +1144,8 @@ fn encode_responses_input( fn encode_responses_special_input( block: &ContentBlock, namespaces: Option<&Map>, + custom_tools: &std::collections::HashSet, + custom_call_ids: &mut std::collections::HashSet, ) -> Option { match block { ContentBlock::Reasoning { @@ -1076,6 +1153,16 @@ fn encode_responses_special_input( signature: None, details, } => encode_responses_reasoning_input(text, details), + ContentBlock::ToolCall(call) if custom_tools.contains(&call.name) => { + // A freeform tool call replays as `custom_tool_call` with its raw input. + custom_call_ids.insert(call.id.clone()); + Some(json!({ + "type": "custom_tool_call", + "call_id": call.id, + "name": call.name, + "input": crate::codex_custom_tools::input_from_arguments(&call.arguments), + })) + } ContentBlock::ToolCall(call) => { // A Responses client dispatches on name plus namespace, so undo the // qualification this request applied for a flat upstream. @@ -1098,7 +1185,11 @@ fn encode_responses_special_input( Some(item) } ContentBlock::ToolResult(result) => Some(json!({ - "type": "function_call_output", + "type": if custom_call_ids.contains(&result.tool_call_id) { + "custom_tool_call_output" + } else { + "function_call_output" + }, "call_id": result.tool_call_id, "output": text_from_blocks(&result.content, " "), })), @@ -1225,10 +1316,16 @@ fn encode_responses_content( fn encode_responses_tools( tools: &[ToolDefinition], namespaces: Option<&Map>, + custom_tools: Option<&Map>, ) -> Value { let mut out: Vec = Vec::new(); let mut containers: Vec<(String, Vec)> = Vec::new(); for tool in tools { + // A freeform tool goes back out exactly as the client defined it. + if let Some(custom) = custom_tools.and_then(|custom| custom.get(&tool.name)) { + out.push(custom.clone()); + continue; + } let mut item = json!({ "type": "function", "name": tool.name, @@ -1326,6 +1423,26 @@ fn decode_responses_output_item( })], stop_reason: Some(StopReason::ToolUse), })), + Some("custom_tool_call") => Ok(Some(ResponseOutput { + role: Role::Assistant, + content: vec![ContentBlock::ToolCall(ToolCall { + id: item + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: json!({ + crate::codex_custom_tools::INPUT_ARGUMENT: + item.get("input").and_then(Value::as_str).unwrap_or_default() + }), + })], + stop_reason: Some(StopReason::ToolUse), + })), Some("reasoning") => Ok(Some(ResponseOutput { role: Role::Assistant, content: decode_responses_reasoning_item(item), diff --git a/crates/switchyard-translation/src/codecs/responses/stream.rs b/crates/switchyard-translation/src/codecs/responses/stream.rs index d3a6ea1e7..3b2920789 100644 --- a/crates/switchyard-translation/src/codecs/responses/stream.rs +++ b/crates/switchyard-translation/src/codecs/responses/stream.rs @@ -604,14 +604,20 @@ fn decode_responses_output_item_added( if item.get("type").and_then(Value::as_str) == Some("reasoning") { return decode_responses_reasoning_item(item, index, state); } - if item.get("type").and_then(Value::as_str) != Some("function_call") { + let item_type = item.get("type").and_then(Value::as_str); + if item_type != Some("function_call") && item_type != Some("custom_tool_call") { return Vec::new(); } - let arguments_delta = item - .get("arguments") - .and_then(Value::as_str) - .filter(|arguments| !arguments.is_empty()) - .map(ToOwned::to_owned); + // A freeform call's `input` becomes the single `input` argument; it is only complete on + // the done event, so nothing is emitted for it here beyond id and name. + let arguments_delta = if item_type == Some("custom_tool_call") { + None + } else { + item.get("arguments") + .and_then(Value::as_str) + .filter(|arguments| !arguments.is_empty()) + .map(ToOwned::to_owned) + }; if let Some(arguments) = arguments_delta.as_deref() { state .tool_states @@ -650,10 +656,20 @@ fn decode_responses_output_item_done( if item.get("type").and_then(Value::as_str) == Some("reasoning") { return decode_responses_reasoning_item(item, index, state); } - if item.get("type").and_then(Value::as_str) != Some("function_call") { + let item_type = item.get("type").and_then(Value::as_str); + if item_type != Some("function_call") && item_type != Some("custom_tool_call") { return Vec::new(); } - let arguments = item.get("arguments").and_then(Value::as_str); + let custom_arguments = (item_type == Some("custom_tool_call")).then(|| { + json!({ + crate::codex_custom_tools::INPUT_ARGUMENT: + item.get("input").and_then(Value::as_str).unwrap_or_default() + }) + .to_string() + }); + let arguments = custom_arguments + .as_deref() + .or_else(|| item.get("arguments").and_then(Value::as_str)); if let Some(arguments) = arguments { // Compared against what THIS decoder has seen. Reading the encoder's // `arguments` instead only deduplicates when a single state performs diff --git a/crates/switchyard-translation/src/codex_custom_tools.rs b/crates/switchyard-translation/src/codex_custom_tools.rs new file mode 100644 index 000000000..2ae4464b9 --- /dev/null +++ b/crates/switchyard-translation/src/codex_custom_tools.rs @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Round-trips OpenAI Responses freeform ("custom") tools through the neutral IR. +//! +//! Codex drives GPT-5 models with freeform tools: the definition is `{"type": "custom", "name", +//! "description", "format"}` and the model answers with `custom_tool_call` items whose `input` +//! is a raw string rather than JSON arguments. The IR only knows function-style tools, so a +//! custom tool is represented as a function whose single argument is `input`, and the verbatim +//! definitions are kept on the request extensions. When the response is encoded back to +//! Responses, calls to those tools are rewritten into `custom_tool_call` items again. + +use std::collections::HashSet; + +use serde_json::{Map, Value, json}; +use switchyard_protocol::ProviderExtensions; + +/// Request-extension key holding the verbatim custom tool definitions, keyed by tool name. +/// +/// Prefixed so it cannot collide with a real provider field, and so a codec that allowlists +/// provider fields never forwards it. +pub const CUSTOM_TOOLS_KEY: &str = "switchyard_codex_custom_tools"; + +/// Argument name used to carry a custom tool's freeform input through the IR. +pub const INPUT_ARGUMENT: &str = "input"; + +/// The IR parameter schema for a custom tool: one required string, `input`. +pub fn input_schema() -> Value { + json!({ + "type": "object", + "properties": {INPUT_ARGUMENT: {"type": "string"}}, + "required": [INPUT_ARGUMENT], + "additionalProperties": false, + }) +} + +/// Stores the collected definitions on a request's extensions, when there are any. +pub fn attach_custom_tools(extensions: &mut ProviderExtensions, tools: Map) { + if !tools.is_empty() { + extensions + .fields + .insert(CUSTOM_TOOLS_KEY.to_string(), Value::Object(tools)); + } +} + +/// Reads the definitions back off a request's extensions. +pub fn custom_tools(extensions: &ProviderExtensions) -> Option<&Map> { + extensions + .fields + .get(CUSTOM_TOOLS_KEY) + .and_then(Value::as_object) +} + +/// Names of the custom tools recorded on a request. +pub fn custom_tool_names(extensions: &ProviderExtensions) -> HashSet { + custom_tools(extensions) + .map(|tools| tools.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Extracts the freeform input from IR tool arguments, falling back to the serialized +/// arguments when the model did not use the `input` convention. +pub fn input_from_arguments(arguments: &Value) -> String { + match arguments { + Value::Object(object) => match object.get(INPUT_ARGUMENT) { + Some(Value::String(input)) => input.clone(), + Some(other) => other.to_string(), + None => arguments.to_string(), + }, + Value::String(text) => match serde_json::from_str::(text) { + Ok(Value::Object(object)) => match object.get(INPUT_ARGUMENT) { + Some(Value::String(input)) => input.clone(), + _ => text.clone(), + }, + _ => text.clone(), + }, + other => other.to_string(), + } +} + +/// Rewrites a `function_call` output item into a `custom_tool_call` when the tool is custom. +/// Returns whether the item was rewritten. +fn rewrite_item(item: &mut Value, custom: &HashSet) -> bool { + let Some(object) = item.as_object_mut() else { + return false; + }; + if object.get("type").and_then(Value::as_str) != Some("function_call") { + return false; + } + let Some(name) = object.get("name").and_then(Value::as_str) else { + return false; + }; + if !custom.contains(name) { + return false; + } + let input = object + .remove("arguments") + .map(|arguments| input_from_arguments(&arguments)) + .unwrap_or_default(); + object.insert( + "type".to_string(), + Value::String("custom_tool_call".to_string()), + ); + object.insert("input".to_string(), Value::String(input)); + true +} + +/// Rewrites custom tool calls inside a buffered Responses body's `output` array. +pub fn restore_custom_tool_calls(body: &mut Value, custom: &HashSet) { + if custom.is_empty() { + return; + } + if let Some(items) = body.get_mut("output").and_then(Value::as_array_mut) { + for item in items { + rewrite_item(item, custom); + } + } +} + +/// Per-stream bookkeeping for [`restore_custom_tool_calls_in_event`]. +#[derive(Default)] +pub struct CustomToolCallStreamState { + /// Output indexes whose item was rewritten into a custom tool call. + custom_indexes: HashSet, +} + +/// Rewrites a streamed Responses event so a custom tool's call reaches the client in the shape +/// it expects. Item events are rewritten in place; argument delta events for a rewritten item +/// are dropped (returns `false`), because a partial JSON delta cannot be turned into a +/// freeform input delta and clients read the completed item instead. +pub fn restore_custom_tool_calls_in_event( + event: &mut Value, + custom: &HashSet, + state: &mut CustomToolCallStreamState, +) -> bool { + if custom.is_empty() { + return true; + } + let kind = event + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let index = event.get("output_index").and_then(Value::as_u64); + match kind.as_str() { + "response.output_item.added" | "response.output_item.done" => { + if let Some(item) = event.get_mut("item") + && rewrite_item(item, custom) + && let Some(index) = index + { + state.custom_indexes.insert(index); + } + true + } + "response.function_call_arguments.delta" | "response.function_call_arguments.done" => { + !index.is_some_and(|index| state.custom_indexes.contains(&index)) + } + "response.completed" | "response.incomplete" | "response.failed" => { + if let Some(response) = event.get_mut("response") { + restore_custom_tool_calls(response, custom); + } + true + } + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_is_read_from_the_input_argument_or_left_verbatim() { + assert_eq!(input_from_arguments(&json!({"input": "ls -la"})), "ls -la"); + assert_eq!(input_from_arguments(&json!("{\"input\":\"pwd\"}")), "pwd"); + assert_eq!(input_from_arguments(&json!("raw text")), "raw text"); + assert_eq!( + input_from_arguments(&json!({"cmd": "x"})), + "{\"cmd\":\"x\"}" + ); + } + + #[test] + fn function_call_items_for_custom_tools_become_custom_tool_calls() { + let custom: HashSet = ["exec".to_string()].into_iter().collect(); + let mut body = json!({"output": [ + {"type": "function_call", "call_id": "c1", "name": "exec", "arguments": "{\"input\":\"ls\"}"}, + {"type": "function_call", "call_id": "c2", "name": "update_plan", "arguments": "{}"} + ]}); + restore_custom_tool_calls(&mut body, &custom); + assert_eq!(body["output"][0]["type"], "custom_tool_call"); + assert_eq!(body["output"][0]["input"], "ls"); + assert!(body["output"][0].get("arguments").is_none()); + assert_eq!(body["output"][1]["type"], "function_call"); + } + + #[test] + fn streamed_argument_deltas_for_custom_tools_are_dropped() { + let custom: HashSet = ["exec".to_string()].into_iter().collect(); + let mut state = CustomToolCallStreamState::default(); + let mut added = json!({"type": "response.output_item.added", "output_index": 1, + "item": {"type": "function_call", "call_id": "c1", "name": "exec", "arguments": ""}}); + assert!(restore_custom_tool_calls_in_event( + &mut added, &custom, &mut state + )); + assert_eq!(added["item"]["type"], "custom_tool_call"); + let mut delta = json!({"type": "response.function_call_arguments.delta", "output_index": 1, "delta": "{\"in"}); + assert!(!restore_custom_tool_calls_in_event( + &mut delta, &custom, &mut state + )); + let mut other = json!({"type": "response.function_call_arguments.delta", "output_index": 2, "delta": "{}"}); + assert!(restore_custom_tool_calls_in_event( + &mut other, &custom, &mut state + )); + let mut done = json!({"type": "response.output_item.done", "output_index": 1, + "item": {"type": "function_call", "call_id": "c1", "name": "exec", "arguments": "{\"input\":\"ls -la\"}"}}); + assert!(restore_custom_tool_calls_in_event( + &mut done, &custom, &mut state + )); + assert_eq!(done["item"]["input"], "ls -la"); + } +} diff --git a/crates/switchyard-translation/src/engine.rs b/crates/switchyard-translation/src/engine.rs index abd30955c..4bd99f8e7 100644 --- a/crates/switchyard-translation/src/engine.rs +++ b/crates/switchyard-translation/src/engine.rs @@ -217,6 +217,10 @@ impl TranslationEngine { &mut output.body, &crate::codex_namespaces::qualified_tool_origins(request_extensions), ); + crate::codex_custom_tools::restore_custom_tool_calls( + &mut output.body, + &crate::codex_custom_tools::custom_tool_names(request_extensions), + ); Ok(output) } diff --git a/crates/switchyard-translation/src/helpers.rs b/crates/switchyard-translation/src/helpers.rs index 061517e24..95ebb75e7 100644 --- a/crates/switchyard-translation/src/helpers.rs +++ b/crates/switchyard-translation/src/helpers.rs @@ -125,6 +125,8 @@ pub fn encode_stream_with_extensions( request_extensions: &switchyard_protocol::ProviderExtensions, ) -> std::result::Result { let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions); + let custom_tools = crate::codex_custom_tools::custom_tool_names(request_extensions); + let mut custom_state = crate::codex_custom_tools::CustomToolCallStreamState::default(); let target_format: FormatId = target.into(); // The target is always a built-in wire format, so this lookup cannot fail; a // failure returns as an `Err` rather than a panic. @@ -152,6 +154,19 @@ pub fn encode_stream_with_extensions( stamp_streamed_response_model(value, target, served_model_for_events.as_deref()); crate::codex_namespaces::restore_qualified_tool_names(value, &origins); } + // Argument deltas for a freeform tool cannot be expressed on the wire; the + // rewritten completed item carries the input instead. + let mut encoded: Vec = encoded + .into_iter() + .filter_map(|mut value| { + crate::codex_custom_tools::restore_custom_tool_calls_in_event( + &mut value, + &custom_tools, + &mut custom_state, + ) + .then_some(value) + }) + .collect(); let terminal = encoded.pop(); for value in encoded { yield value; @@ -172,7 +187,13 @@ pub fn encode_stream_with_extensions( served_model_for_events.as_deref(), ); crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins); - yield value; + if crate::codex_custom_tools::restore_custom_tool_calls_in_event( + &mut value, + &custom_tools, + &mut custom_state, + ) { + yield value; + } } }; diff --git a/crates/switchyard-translation/src/lib.rs b/crates/switchyard-translation/src/lib.rs index 8f9bbe976..4d0e81da6 100644 --- a/crates/switchyard-translation/src/lib.rs +++ b/crates/switchyard-translation/src/lib.rs @@ -8,6 +8,7 @@ //! servers, Python objects, or FFI bindings. pub mod codecs; +pub(crate) mod codex_custom_tools; pub(crate) mod codex_namespaces; pub mod diagnostic; pub mod engine; diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index d7169c8a0..60af12c16 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -2484,3 +2484,82 @@ fn responses_flat_file_data_survives_into_chat() -> TestResult { assert_eq!(file["file"]["filename"], "report.pdf"); Ok(()) } + +// Codex drives GPT-5 models with freeform ("custom") tools. Through a Responses upstream the +// definition must go out verbatim and the replayed history must keep `custom_tool_call` items +// with their raw `input`; through a chat upstream the tool degrades to a single-argument function. +#[test] +fn responses_request_round_trips_custom_tools_and_custom_tool_calls() -> TestResult { + let engine = TranslationEngine::default(); + let custom_tool = json!({ + "type": "custom", + "name": "exec", + "description": "Runs a shell command.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.*/"} + }); + let body = json!({ + "model": "gpt-5.6-luna", + "input": [ + {"type": "message", "role": "user", "content": "List files"}, + {"type": "custom_tool_call", "call_id": "call_1", "name": "exec", "input": "ls -la"}, + {"type": "custom_tool_call_output", "call_id": "call_1", "output": "README.md"} + ], + "tools": [ + custom_tool, + {"type": "function", "name": "update_plan", "description": "Plan", "parameters": {"type": "object"}} + ] + }); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + + let same = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &policy, + )? + .body; + let tools = same["tools"].as_array().ok_or("tools should be an array")?; + assert!( + tools.iter().any(|tool| tool == &custom_tool), + "custom tool must be re-emitted verbatim: {tools:?}" + ); + let input = same["input"].as_array().ok_or("input should be an array")?; + let call = input + .iter() + .find(|item| item["type"] == "custom_tool_call") + .ok_or("history must keep the custom_tool_call")?; + assert_eq!(call["name"], "exec"); + assert_eq!(call["call_id"], "call_1"); + assert_eq!(call["input"], "ls -la"); + assert!(call.get("arguments").is_none(), "{call}"); + let output = input + .iter() + .find(|item| item["type"] == "custom_tool_call_output") + .ok_or("history must keep the custom_tool_call_output")?; + assert_eq!(output["call_id"], "call_1"); + + let chat = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + let exec = chat["tools"] + .as_array() + .ok_or("chat tools should be an array")? + .iter() + .find(|tool| tool["function"]["name"] == "exec") + .ok_or("chat upstream should still see the tool")?; + assert_eq!( + exec["function"]["parameters"]["required"], + json!(["input"]), + "{exec}" + ); + Ok(()) +} diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 16b9efd27..e68a1ad61 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -782,3 +782,76 @@ fn responses_encrypted_reasoning_item_survives_buffered_round_trip() -> TestResu assert_eq!(reasoning["id"], "rs_upstream"); Ok(()) } + +// A freeform tool call returned by the upstream must reach the client as a `custom_tool_call` +// again once the response is re-encoded with the request's extensions, and as a function-style +// call with an `input` argument when the client speaks chat. +#[test] +fn responses_custom_tool_call_output_round_trips_with_request_extensions() -> TestResult { + let engine = TranslationEngine::default(); + let request = json!({ + "model": "gpt-5.6-luna", + "input": "List files", + "tools": [{ + "type": "custom", + "name": "exec", + "description": "Runs a shell command.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.*/"} + }] + }); + let decoded_request = engine.decode_request( + WireFormat::OpenAiResponses, + &request, + &TranslationPolicy::default(), + )?; + let response = json!({ + "id": "resp_1", + "object": "response", + "status": "completed", + "model": "gpt-5.6-luna", + "output": [{ + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_1", + "name": "exec", + "input": "ls -la", + "status": "completed" + }], + "usage": {"input_tokens": 4, "output_tokens": 3, "total_tokens": 7} + }); + let policy = TranslationPolicy { + preservation: PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + + let ir = engine + .decode_response(WireFormat::OpenAiResponses, &response, &policy)? + .response; + let encoded = engine + .encode_response_with_extensions( + WireFormat::OpenAiResponses, + &ir, + &decoded_request.request.extensions, + &policy, + )? + .body; + let item = encoded["output"] + .as_array() + .ok_or("output should be an array")? + .iter() + .find(|item| item["type"] == "custom_tool_call") + .ok_or("the call must be re-emitted as custom_tool_call")?; + assert_eq!(item["name"], "exec"); + assert_eq!(item["call_id"], "call_1"); + assert_eq!(item["input"], "ls -la"); + assert!(item.get("arguments").is_none(), "{item}"); + + // Without the request extensions (e.g. a plain chat client) the call stays function-style. + let chat = engine + .encode_response(WireFormat::OpenAiChat, &ir, &policy)? + .body; + let call = &chat["choices"][0]["message"]["tool_calls"][0]; + assert_eq!(call["function"]["name"], "exec"); + assert_eq!(call["function"]["arguments"], "{\"input\":\"ls -la\"}"); + Ok(()) +} From 37b80ff82a52e25c199a2d5a4febc3c79a877f2a Mon Sep 17 00:00:00 2001 From: Lin Jia Date: Tue, 8 Sep 2026 12:52:18 -0700 Subject: [PATCH 2/3] feat(translation): understand Responses-lite additional_tools input items Codex sends GPT-5 requests in a lite shape: no top-level tools, empty instructions, the tool definitions inside input[0] as an additional_tools developer item, and the base instructions as a developer message. The Responses codec did not know the item, so a routed GPT-5 session had no tools in the IR and the item was turned into a user message carrying the tool JSON. The request decoder now reads the item's tools as the request's tool definitions (including freeform tools) and keeps the array verbatim on the request extensions; the input decoder skips the item; the request encoder re-emits it in place and leaves top-level tools absent, so a Responses upstream receives the request in the shape the client used, while a chat upstream receives ordinary function tools. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Lin Jia --- .../src/codecs/responses/buffered.rs | 42 ++++++- .../src/codex_custom_tools.rs | 25 +++++ .../tests/request_translation.rs | 104 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 05b40dfa1..7f1dddbd3 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -94,6 +94,26 @@ impl FormatCodec for OpenAiResponsesCodec { let mut custom_tools = Map::new(); request.tools = decode_responses_tools(body.get("tools"), &mut tool_namespaces, &mut custom_tools); + // Responses-lite clients (Codex with a GPT-5 model) carry the tool definitions inside + // `input` as an `additional_tools` developer item instead of top-level `tools`. Those + // definitions are the request's tools; the item itself is kept verbatim so the request + // can be re-emitted in the shape the client used. + let mut additional_tools = Vec::new(); + if let Some(items) = body.get("input").and_then(Value::as_array) { + for item in items { + if let Some(item) = item.as_object() + && item.get("type").and_then(Value::as_str) == Some("additional_tools") + && let Some(tools) = item.get("tools").and_then(Value::as_array) + { + request.tools.extend(decode_responses_tools( + Some(&Value::Array(tools.clone())), + &mut tool_namespaces, + &mut custom_tools, + )); + additional_tools.extend(tools.iter().cloned()); + } + } + } request.tool_choice = body .get("tool_choice") .and_then(decode_responses_tool_choice); @@ -115,6 +135,10 @@ impl FormatCodec for OpenAiResponsesCodec { ); crate::codex_namespaces::attach_tool_namespaces(&mut request.extensions, tool_namespaces); crate::codex_custom_tools::attach_custom_tools(&mut request.extensions, custom_tools); + crate::codex_custom_tools::attach_additional_tools( + &mut request.extensions, + additional_tools, + ); Ok(DecodedRequest { request, diagnostics, @@ -163,7 +187,20 @@ impl FormatCodec for OpenAiResponsesCodec { &crate::codex_custom_tools::custom_tool_names(&request.extensions), )?, ); - if !request.tools.is_empty() { + if let Some(additional) = crate::codex_custom_tools::additional_tools(&request.extensions) { + // A Responses-lite request carried its tools inside `input`; give them back the same + // way, verbatim, and leave top-level `tools` absent as the client did. + if let Some(Value::Array(input)) = body.get_mut("input") { + input.insert( + 0, + json!({ + "type": "additional_tools", + "role": "developer", + "tools": additional, + }), + ); + } + } else if !request.tools.is_empty() { body.insert( "tools".to_string(), encode_responses_tools( @@ -539,6 +576,9 @@ fn decode_responses_input( .to_string(), }); } + // Tool definitions, not conversation; decoded separately by the request + // decoder and re-emitted in place by the request encoder. + Some("additional_tools") => {} _ => { let message = Message { role: Role::User, diff --git a/crates/switchyard-translation/src/codex_custom_tools.rs b/crates/switchyard-translation/src/codex_custom_tools.rs index 2ae4464b9..ea93c2649 100644 --- a/crates/switchyard-translation/src/codex_custom_tools.rs +++ b/crates/switchyard-translation/src/codex_custom_tools.rs @@ -21,6 +21,31 @@ use switchyard_protocol::ProviderExtensions; /// provider fields never forwards it. pub const CUSTOM_TOOLS_KEY: &str = "switchyard_codex_custom_tools"; +/// Request-extension key holding the verbatim `tools` array of a Responses-lite +/// `additional_tools` input item, so the request can be re-emitted in the same shape. +/// +/// Codex sends GPT-5 requests in a "lite" shape: no top-level `tools`, empty `instructions`, +/// and the tool definitions inside `input[0]` as `{"type": "additional_tools", "role": +/// "developer", "tools": [...]}`. +pub const ADDITIONAL_TOOLS_KEY: &str = "switchyard_codex_additional_tools"; + +/// Stores the verbatim tools array of an `additional_tools` input item. +pub fn attach_additional_tools(extensions: &mut ProviderExtensions, tools: Vec) { + if !tools.is_empty() { + extensions + .fields + .insert(ADDITIONAL_TOOLS_KEY.to_string(), Value::Array(tools)); + } +} + +/// Reads the verbatim `additional_tools` array back off a request's extensions. +pub fn additional_tools(extensions: &ProviderExtensions) -> Option<&Vec> { + extensions + .fields + .get(ADDITIONAL_TOOLS_KEY) + .and_then(Value::as_array) +} + /// Argument name used to carry a custom tool's freeform input through the IR. pub const INPUT_ARGUMENT: &str = "input"; diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 60af12c16..a49ff9972 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -2563,3 +2563,107 @@ fn responses_request_round_trips_custom_tools_and_custom_tool_calls() -> TestRes ); Ok(()) } + +// Codex sends GPT-5 requests in the Responses-lite shape: no top-level `tools`, empty +// `instructions`, the tool definitions inside `input[0]` as an `additional_tools` developer +// item, and the base instructions as a developer message. Those definitions are the request's +// tools, the item must not leak into the conversation, and a Responses upstream must receive +// the request in the same shape. +#[test] +fn responses_lite_additional_tools_item_is_the_tool_list() -> TestResult { + let engine = TranslationEngine::default(); + let tools = json!([ + {"type": "custom", "name": "exec", "description": "Run JS.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.*/"}}, + {"type": "function", "name": "update_plan", "description": "Plan", + "parameters": {"type": "object", "properties": {}}} + ]); + let body = json!({ + "model": "gpt-5.6-luna-switchyard", + "instructions": "", + "input": [ + {"type": "additional_tools", "role": "developer", "tools": tools}, + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "You are Codex."}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "List files"}]}, + {"type": "custom_tool_call", "call_id": "call_1", "name": "exec", "input": "ls"}, + {"type": "custom_tool_call_output", "call_id": "call_1", "output": "README.md"} + ], + "stream": true + }); + let policy = TranslationPolicy { + preservation: switchyard_translation::PreservationPolicy::Disabled, + ..TranslationPolicy::default() + }; + + let decoded = engine.decode_request(WireFormat::OpenAiResponses, &body, &policy)?; + let names = decoded + .request + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["exec", "update_plan"]); + assert!( + !decoded.request.messages.iter().any(|message| { + message + .content + .iter() + .any(|block| matches!(block, switchyard_protocol::ContentBlock::Unknown { .. })) + }), + "the additional_tools item must not become a conversation message" + ); + + let same = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &policy, + )? + .body; + assert!(same.get("tools").is_none(), "{same}"); + let input = same["input"].as_array().ok_or("input should be an array")?; + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[0]["role"], "developer"); + assert_eq!(input[0]["tools"], tools); + assert!( + input + .iter() + .skip(1) + .all(|item| item["type"] != "additional_tools"), + "{same}" + ); + assert!( + input + .iter() + .any(|item| item["type"] == "custom_tool_call" && item["input"] == "ls"), + "{same}" + ); + + let chat = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + let chat_tools = chat["tools"] + .as_array() + .ok_or("chat tools should be an array")?; + let chat_names = chat_tools + .iter() + .map(|tool| tool["function"]["name"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(chat_names, vec!["exec", "update_plan"]); + let messages = chat["messages"] + .as_array() + .ok_or("messages should be an array")?; + assert!( + !messages + .iter() + .any(|message| message["content"].to_string().contains("additional_tools")), + "{chat}" + ); + Ok(()) +} From ae0d2f06f2ae053812ab53587a1c8af04b9bfbfb Mon Sep 17 00:00:00 2001 From: Lin Jia Date: Tue, 8 Sep 2026 13:29:08 -0700 Subject: [PATCH 3/3] fix(translation): give rewritten custom tool calls a ctc item id prefix OpenAI validates replayed item ids by prefix and rejects a custom_tool_call whose id starts with fc_ ("Expected an ID that begins with 'ctc'"). When a function_call item is rewritten into a custom_tool_call for the client, its synthesized id now takes the ctc_ prefix. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Lin Jia --- .../src/codex_custom_tools.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/switchyard-translation/src/codex_custom_tools.rs b/crates/switchyard-translation/src/codex_custom_tools.rs index ea93c2649..7d15d56bc 100644 --- a/crates/switchyard-translation/src/codex_custom_tools.rs +++ b/crates/switchyard-translation/src/codex_custom_tools.rs @@ -127,6 +127,12 @@ fn rewrite_item(item: &mut Value, custom: &HashSet) -> bool { Value::String("custom_tool_call".to_string()), ); object.insert("input".to_string(), Value::String(input)); + // OpenAI validates replayed item ids by prefix: a custom tool call must be `ctc_...`. + if let Some(Value::String(id)) = object.get_mut("id") + && let Some(rest) = id.strip_prefix("fc_") + { + *id = format!("ctc_{rest}"); + } true } @@ -219,6 +225,17 @@ mod tests { assert_eq!(body["output"][1]["type"], "function_call"); } + #[test] + fn rewritten_custom_tool_calls_take_the_ctc_id_prefix() { + let custom: HashSet = ["exec".to_string()].into_iter().collect(); + let mut body = json!({"output": [ + {"type": "function_call", "id": "fc_abc_1", "call_id": "c1", "name": "exec", "arguments": "{\"input\":\"ls\"}"} + ]}); + restore_custom_tool_calls(&mut body, &custom); + assert_eq!(body["output"][0]["type"], "custom_tool_call"); + assert_eq!(body["output"][0]["id"], "ctc_abc_1"); + } + #[test] fn streamed_argument_deltas_for_custom_tools_are_dropped() { let custom: HashSet = ["exec".to_string()].into_iter().collect();