diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 10fab3684..7f1dddbd3 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -91,7 +91,29 @@ 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); + // 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); @@ -112,6 +134,11 @@ 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, @@ -157,14 +184,29 @@ 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() { + 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( &request.tools, crate::codex_namespaces::tool_namespaces(&request.extensions), + crate::codex_custom_tools::custom_tools(&request.extensions), ), ); } @@ -475,7 +517,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 +530,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"), @@ -495,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, @@ -776,6 +860,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 +877,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 +893,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 +1103,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 +1140,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 +1184,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 +1193,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 +1225,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 +1356,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 +1463,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..7d15d56bc --- /dev/null +++ b/crates/switchyard-translation/src/codex_custom_tools.rs @@ -0,0 +1,264 @@ +// 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"; + +/// 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"; + +/// 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)); + // 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 +} + +/// 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 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(); + 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..a49ff9972 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -2484,3 +2484,186 @@ 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(()) +} + +// 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(()) +} 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(()) +}