diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index b3a7f7f2c..44ed94b85 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -88,7 +88,32 @@ impl FormatCodec for OpenAiResponsesCodec { policy, )?; let mut tool_namespaces = Map::new(); - request.tools = decode_responses_tools(body.get("tools"), &mut tool_namespaces); + let mut custom_tools = Map::new(); + let mut additional_tools = Map::new(); + request.tools = + decode_responses_tools(body.get("tools"), &mut tool_namespaces, &mut custom_tools); + // Codex 0.146 declares its tools in an `additional_tools` input item rather + // than in `tools`, so a request that offers a full toolset arrives with no + // `tools` key at all. Decoding the item here is what lets those tools reach + // an upstream; left in the input they become opaque user content and the + // model is offered nothing to call. + let specs = crate::codex_tools::additional_tool_specs(body.get("input")); + if !specs.is_empty() { + let decoded = decode_responses_tools( + Some(&Value::Array(specs)), + &mut tool_namespaces, + &mut custom_tools, + ); + for tool in decoded { + // A name already declared in `tools` wins: it is the more specific + // declaration, and two entries would be an invalid toolset. + if request.tools.iter().any(|known| known.name == tool.name) { + continue; + } + crate::codex_tools::record_additional_tool(&mut additional_tools, &tool.name); + request.tools.push(tool); + } + } request.tool_choice = body .get("tool_choice") .and_then(decode_responses_tool_choice); @@ -109,6 +134,8 @@ impl FormatCodec for OpenAiResponsesCodec { ], ); crate::codex_namespaces::attach_tool_namespaces(&mut request.extensions, tool_namespaces); + crate::codex_tools::attach_custom_tools(&mut request.extensions, custom_tools); + crate::codex_tools::attach_additional_tools(&mut request.extensions, additional_tools); Ok(DecodedRequest { request, diagnostics, @@ -147,21 +174,53 @@ impl FormatCodec for OpenAiResponsesCodec { if !instructions.is_empty() { body.insert("instructions".to_string(), Value::String(instructions)); } - body.insert( - "input".to_string(), + // Tools the client declared through an `additional_tools` item go back + // there, so a Responses-to-Responses hop hands the upstream the same + // request shape it was given. + let additional_names = crate::codex_tools::additional_tool_names(&request.extensions); + let (additional, plain): (Vec, Vec) = request + .tools + .iter() + .cloned() + .partition(|tool| additional_names.contains(&tool.name)); + let input = if additional.is_empty() { encode_responses_input( &request.messages, &mut diagnostics, _policy, crate::codex_namespaces::tool_namespaces(&request.extensions), - )?, - ); - if !request.tools.is_empty() { + )? + } else { + // The single-user-message shorthand is a bare string, which has no room + // for a declaration item, so the list form is required here. + let mut items = encode_responses_input_items( + &request.messages, + &mut diagnostics, + _policy, + crate::codex_namespaces::tool_namespaces(&request.extensions), + )?; + items.insert( + 0, + json!({ + "type": "additional_tools", + "role": "developer", + "tools": encode_responses_tools( + &additional, + crate::codex_namespaces::tool_namespaces(&request.extensions), + &crate::codex_tools::custom_tool_names(&request.extensions), + ), + }), + ); + Value::Array(items) + }; + body.insert("input".to_string(), input); + if !plain.is_empty() { body.insert( "tools".to_string(), encode_responses_tools( - &request.tools, + &plain, crate::codex_namespaces::tool_namespaces(&request.extensions), + &crate::codex_tools::custom_tool_names(&request.extensions), ), ); } @@ -456,6 +515,63 @@ fn decode_responses_input( arguments: item.get("arguments").cloned().unwrap_or_else(|| json!({})), }); } + Some("custom_tool_call") => { + // Codex replays its freeform calls as `custom_tool_call`. + // Left unhandled these reach the catch-all below and become + // opaque user content, which teaches the model to write its + // next call as prose instead of calling the tool. + 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 input = item + .get("input") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + pending_tool_calls.push(ToolCall { + id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + // Spelled the way the tool is advertised on this wire, + // so the transcript matches the offered schema. + arguments: json!({ + crate::codex_tools::CUSTOM_INPUT_PROPERTY: input + }), + }); + } + Some("custom_tool_call_output") => { + pending_tool_outputs.push(ToolResult { + tool_call_id: item + .get("call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content: vec![ContentBlock::Text { + text: item.get("output").map(json_string).unwrap_or_default(), + }], + is_error: None, + }); + } Some("function_call_output") => { let tool_call_id = item .get("call_id") @@ -469,6 +585,12 @@ fn decode_responses_input( is_error: None, }); } + Some("additional_tools") => { + // A tool declaration, not conversation. `decode_request` + // has already lifted it into the request's tool list, so + // emitting it as content here would send the same 24 KB of + // schemas twice and, on a chat upstream, as prose. + } None => { return Err(TranslationError::InvalidValue { path: format!("$.input[{index}].type"), @@ -776,6 +898,7 @@ fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { fn decode_responses_tools( value: Option<&Value>, namespaces: &mut Map, + customs: &mut Map, ) -> Vec { let Some(tools) = value.and_then(Value::as_array) else { return Vec::new(); @@ -792,7 +915,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, customs) { // 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 +931,28 @@ fn decode_responses_tools( } out.push(child); } + } else if tool.get("type").and_then(Value::as_str) == Some("custom") { + // A Codex freeform tool. A chat upstream has no `custom` type, so it is + // advertised as a function taking one string and recorded here, letting + // the response codec turn the call back into a `custom_tool_call`. + // Without this arm it falls through to the id-keyed fallback, which + // finds no `id` and drops the tool outright. + if let Some(name) = tool + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + { + crate::codex_tools::record_custom_tool(customs, name); + out.push(ToolDefinition { + name: name.to_string(), + description: tool + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + parameters: crate::codex_tools::custom_tool_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) @@ -1007,6 +1152,17 @@ fn encode_responses_input( { return Ok(Value::String(text.clone())); } + encode_responses_input_items(messages, diagnostics, policy, namespaces).map(Value::Array) +} + +// Encodes messages as Responses input items, without the single-user-message +// shorthand. A caller that has to prepend an item needs the list form. +fn encode_responses_input_items( + messages: &[Message], + diagnostics: &mut Vec, + policy: &TranslationPolicy, + namespaces: Option<&Map>, +) -> Result> { let mut encoded = Vec::new(); for message in messages { // Anthropic-signed thinking cannot be sent as Responses input. @@ -1059,7 +1215,7 @@ fn encode_responses_input( })); } } - Ok(Value::Array(encoded)) + Ok(encoded) } // Encodes IR blocks that Responses represents as top-level input items. @@ -1195,10 +1351,21 @@ fn encode_responses_content( fn encode_responses_tools( tools: &[ToolDefinition], namespaces: Option<&Map>, + customs: &std::collections::HashSet, ) -> Value { let mut out: Vec = Vec::new(); let mut containers: Vec<(String, Vec)> = Vec::new(); for tool in tools { + // A tool the request declared as `custom` goes back out as `custom`, so a + // Responses-to-Responses hop is lossless and the freeform contract holds. + if customs.contains(&tool.name) { + out.push(json!({ + "type": "custom", + "name": tool.name, + "description": tool.description.clone().unwrap_or_default(), + })); + continue; + } let mut item = json!({ "type": "function", "name": tool.name, diff --git a/crates/switchyard-translation/src/codex_tools.rs b/crates/switchyard-translation/src/codex_tools.rs new file mode 100644 index 000000000..8c29eea90 --- /dev/null +++ b/crates/switchyard-translation/src/codex_tools.rs @@ -0,0 +1,505 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Codex tool-declaration shapes, preserved across a Responses/Chat translation. +//! +//! Codex declares its tools in two ways a plain Responses request does not, and +//! both were lost in translation. The symptom of either is the same: the upstream +//! is offered no such tool, the model — still told about it by Codex's own prompt — +//! writes the call as prose, and the turn completes having executed nothing, which +//! reads as success to the client. +//! +//! **`additional_tools` input item.** Codex 0.146 puts its whole toolset in an +//! input item, `{"type": "additional_tools", "role": "developer", "tools": [...]}`, +//! and sends no top-level `tools` key at all. Undecoded, that item reaches the +//! input catch-all and becomes opaque user content. The item's `tools` array holds +//! ordinary Responses tool specs, so the request codec feeds it through the normal +//! tool decoder and records the names here, letting a same-format hop put the +//! declaration back where the client had it. +//! +//! **`custom` tools.** Codex declares its shell and patch tools as Responses +//! **custom** tools, whose input is freeform text rather than JSON arguments: +//! `{"type": "custom", "name": "exec", "format": {"type": "text"}}`. It then +//! expects the call back as a `custom_tool_call` carrying a raw `input` string. An +//! OpenAI-compatible chat upstream has no such tool type — it accepts only +//! `function` tools with a JSON Schema. The request codec therefore exposes each +//! custom tool as a function with a single string property, and this module records +//! which names were originally custom so the response can be turned back into a +//! `custom_tool_call`. +//! +//! The mapping rides in the request's [`ProviderExtensions`] under a prefixed key, +//! so no provider-neutral type grows a Codex-specific field and no codec forwards +//! it to an upstream — the same approach as [`crate::codex_namespaces`]. +//! +//! A `grammar` format cannot be enforced through a JSON Schema, so a +//! grammar-constrained custom tool degrades to an unconstrained string. That is +//! strictly better than dropping it: the model can still call the tool, and Codex +//! validates the input on receipt. + +use std::collections::{HashMap, HashSet}; + +use serde_json::{Map, Value, json}; +use switchyard_protocol::ProviderExtensions; + +/// Request extension key holding the names declared via `additional_tools`. +/// +/// Codex 0.146 declares its tools in an `additional_tools` **input item** rather +/// than the request's `tools` array. Recording which names arrived that way lets a +/// Responses-to-Responses hop put them back where the client had them. +pub const ADDITIONAL_TOOLS_KEY: &str = "switchyard_codex_additional_tools"; + +/// Reads the tool specs out of every `additional_tools` input item. +/// +/// The item's `tools` array holds ordinary Responses tool specs — `function`, +/// `namespace`, and `custom` — so the caller feeds them through the normal tool +/// decoder. Left undecoded the item reaches the input catch-all and becomes +/// opaque user content: the upstream is then offered no tools at all, while the +/// model is still told about them by Codex's prompt. +pub fn additional_tool_specs(input: Option<&Value>) -> Vec { + let Some(items) = input.and_then(Value::as_array) else { + return Vec::new(); + }; + items + .iter() + .filter_map(Value::as_object) + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .filter_map(|item| item.get("tools").and_then(Value::as_array)) + .flatten() + .cloned() + .collect() +} + +/// Records that `name` was declared through an `additional_tools` item. +pub fn record_additional_tool(additional: &mut Map, name: &str) { + additional.insert(name.to_string(), Value::Bool(true)); +} + +/// Stores the collected `additional_tools` names on a request's extensions. +pub fn attach_additional_tools( + extensions: &mut ProviderExtensions, + additional: Map, +) { + if !additional.is_empty() { + extensions + .fields + .insert(ADDITIONAL_TOOLS_KEY.to_string(), Value::Object(additional)); + } +} + +/// Reads the recorded `additional_tools` names back off a request's extensions. +pub fn additional_tool_names(extensions: &ProviderExtensions) -> HashSet { + extensions + .fields + .get(ADDITIONAL_TOOLS_KEY) + .and_then(Value::as_object) + .map(|additional| additional.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Request extension key holding the set of tool names that were `custom`. +/// +/// 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"; + +/// Property name carrying a custom tool's freeform input on the chat wire. +pub const CUSTOM_INPUT_PROPERTY: &str = "input"; + +/// The JSON Schema a custom tool is advertised with on a chat upstream. +/// +/// One required string, because the tool's real contract is freeform text. The +/// description names the tool so a model that reads only the schema still knows +/// what the field is for. +pub fn custom_tool_schema() -> Value { + json!({ + "type": "object", + "properties": { + CUSTOM_INPUT_PROPERTY: { + "type": "string", + "description": "The complete tool input, verbatim, as a single string.", + } + }, + "required": [CUSTOM_INPUT_PROPERTY], + "additionalProperties": false, + }) +} + +/// Records that `name` was declared as a Responses `custom` tool. +pub fn record_custom_tool(customs: &mut Map, name: &str) { + customs.insert(name.to_string(), Value::Bool(true)); +} + +/// Stores a collected set on a request's extensions, when it has entries. +pub fn attach_custom_tools(extensions: &mut ProviderExtensions, customs: Map) { + if !customs.is_empty() { + extensions + .fields + .insert(CUSTOM_TOOLS_KEY.to_string(), Value::Object(customs)); + } +} + +/// Reads the recorded set back off a request's extensions. +pub fn custom_tool_names(extensions: &ProviderExtensions) -> HashSet { + extensions + .fields + .get(CUSTOM_TOOLS_KEY) + .and_then(Value::as_object) + .map(|customs| customs.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Extracts a custom tool's freeform input from chat-style JSON arguments. +/// +/// The advertised schema asks for `{"input": "..."}`, but a model may answer with +/// a bare string, or with the argument object it would have used for a function +/// tool. Each case yields the most faithful text available rather than an error, +/// because a dropped call costs the whole turn. +pub fn custom_input_from_arguments(arguments: &Value) -> String { + match arguments { + Value::String(text) => text.clone(), + Value::Object(object) => match object.get(CUSTOM_INPUT_PROPERTY) { + // The expected shape. + Some(Value::String(text)) => text.clone(), + // A single unnamed argument is unambiguous even under a wrong key. + None if object.len() == 1 => match object.values().next() { + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + None => String::new(), + }, + // Anything else is passed through as JSON: Codex can still read it, + // and inventing a shape here would hide the model's actual output. + Some(other) => other.to_string(), + None => Value::Object(object.clone()).to_string(), + }, + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Item-id prefix a Responses backend requires on a custom tool call. +/// +/// A strict backend validates the prefix per item type: Azure rejects a +/// `custom_tool_call` whose id starts with `fc` -- "Expected an ID that begins +/// with 'ctc'". The client persists the item and replays it next turn, so a +/// wrong prefix here fails the *following* request, not this one. +const CUSTOM_CALL_ID_PREFIX: &str = "ctc"; + +/// Prefix a function call id carries, and which a custom call must not keep. +const FUNCTION_CALL_ID_PREFIX: &str = "fc"; + +/// Re-prefixes a function-call item id for a custom tool call. +/// +/// The suffix is preserved so the id stays as stable and as traceable as the one +/// the upstream or the id policy produced. +fn custom_call_item_id(id: &str) -> String { + if id.starts_with(CUSTOM_CALL_ID_PREFIX) { + return id.to_string(); + } + let suffix = id + .strip_prefix(FUNCTION_CALL_ID_PREFIX) + .unwrap_or(id) + .trim_start_matches('_'); + if suffix.is_empty() { + CUSTOM_CALL_ID_PREFIX.to_string() + } else { + format!("{CUSTOM_CALL_ID_PREFIX}_{suffix}") + } +} + +/// Tracks the streamed items that belong to a custom tool, and their new ids. +/// +/// A Responses stream announces the tool name once, on +/// `response.output_item.added`, and every later delta identifies the item only +/// by `item_id`. Rewriting those deltas therefore needs the mapping this type +/// accumulates as the stream is walked. It maps the id as the upstream sent it to +/// the re-prefixed one, so every reference to the item stays consistent. +#[derive(Debug, Default)] +pub struct CustomToolStreamState { + custom_item_ids: HashMap, +} + +impl CustomToolStreamState { + /// Remembers that the item once called `old_id` is now `new_id`. + fn remember(&mut self, old_id: &str, new_id: &str) { + if !old_id.is_empty() { + self.custom_item_ids + .insert(old_id.to_string(), new_id.to_string()); + } + } + + /// The new id of an item announced as a custom tool call, if any. + fn new_id_of(&self, item_id: &str) -> Option<&str> { + self.custom_item_ids.get(item_id).map(String::as_str) + } +} + +/// Rewrites a `function_call` item into a `custom_tool_call`. +/// +/// Returns the item's old and new id when it was rewritten, so a caller walking a +/// stream can remap the deltas that follow. +fn rewrite_call_item( + object: &mut Map, + names: &HashSet, +) -> Option<(String, String)> { + if object.get("type").and_then(Value::as_str) != Some("function_call") { + return None; + } + let name = object.get("name").and_then(Value::as_str)?; + if !names.contains(name) { + return None; + } + + let input = object + .get("arguments") + .map(custom_input_from_arguments) + .unwrap_or_default(); + object.insert("type".to_string(), Value::String("custom_tool_call".into())); + object.insert("input".to_string(), Value::String(input)); + // `arguments` has no meaning on a custom tool call, and leaving it would + // present the same call twice in two different shapes. + object.remove("arguments"); + + let old_id = object.get("id").and_then(Value::as_str)?.to_string(); + let new_id = custom_call_item_id(&old_id); + object.insert("id".to_string(), Value::String(new_id.clone())); + Some((old_id, new_id)) +} + +/// Turns `function_call` items and their argument deltas back into custom tool +/// calls, for the tool names the request declared as `custom`. +/// +/// Walks the whole value, covering a buffered body and each streaming event. +/// `state` carries the item ids seen so far and must be reused across the events +/// of one stream; a buffered body can pass a fresh one. +pub fn restore_custom_tool_calls( + body: &mut Value, + names: &HashSet, + state: &mut CustomToolStreamState, +) { + if names.is_empty() { + return; + } + restore_in_value(body, names, state); +} + +fn restore_in_value(value: &mut Value, names: &HashSet, state: &mut CustomToolStreamState) { + match value { + Value::Array(values) => { + for value in values { + restore_in_value(value, names, state); + } + } + Value::Object(object) => { + // Children first: an `item` inside a `response.output_item.added` event + // must register its new id before the event's own `item_id` is remapped. + for value in object.values_mut() { + restore_in_value(value, names, state); + } + if let Some((old_id, new_id)) = rewrite_call_item(object, names) { + state.remember(&old_id, &new_id); + } + rewrite_stream_event(object, state); + } + _ => {} + } +} + +/// Renames the argument-delta events of a custom tool call. +/// +/// A Responses client reads a custom tool's input from +/// `response.custom_tool_call_input.delta` / `.done`, not from the +/// `function_call_arguments` events, so an unrenamed delta stream leaves the call +/// with empty input even once the item itself is the right type. +fn rewrite_stream_event(object: &mut Map, state: &CustomToolStreamState) { + // The item is identified only by id here, so an event for a function tool must + // be left alone. Every event that names a rewritten item carries its new id, + // including the ones that keep their own type. + let Some(new_id) = object + .get("item_id") + .and_then(Value::as_str) + .and_then(|item_id| state.new_id_of(item_id)) + .map(ToOwned::to_owned) + else { + return; + }; + object.insert("item_id".to_string(), Value::String(new_id)); + + let Some(event) = object.get("type").and_then(Value::as_str) else { + return; + }; + let renamed = match event { + "response.function_call_arguments.delta" => "response.custom_tool_call_input.delta", + "response.function_call_arguments.done" => "response.custom_tool_call_input.done", + _ => return, + }; + object.insert("type".to_string(), Value::String(renamed.into())); + // The payload field is named for the tool kind as well. + if let Some(delta) = object.remove("delta") { + object.insert("delta".to_string(), delta); + } + if let Some(arguments) = object.remove("arguments") { + object.insert("input".to_string(), arguments); + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Map, json}; + use switchyard_protocol::ProviderExtensions; + + use super::{ + CustomToolStreamState, attach_custom_tools, custom_call_item_id, + custom_input_from_arguments, custom_tool_names, record_custom_tool, + restore_custom_tool_calls, + }; + + fn extensions(names: &[&str]) -> ProviderExtensions { + let mut customs = Map::new(); + for name in names { + record_custom_tool(&mut customs, name); + } + let mut extensions = ProviderExtensions::default(); + attach_custom_tools(&mut extensions, customs); + extensions + } + + #[test] + fn records_and_reads_back_custom_tool_names() { + let names = custom_tool_names(&extensions(&["exec", "apply_patch"])); + assert!(names.contains("exec")); + assert!(names.contains("apply_patch")); + assert_eq!(names.len(), 2); + // An absent mapping is empty rather than an error, so a non-Codex request + // is untouched. + assert!(custom_tool_names(&ProviderExtensions::default()).is_empty()); + } + + // The advertised schema asks for {"input": "..."}, but a model that answers + // in another shape must still produce a usable call. + #[test] + fn recovers_freeform_input_from_every_argument_shape() { + assert_eq!( + custom_input_from_arguments(&json!({"input": "echo hi"})), + "echo hi" + ); + assert_eq!(custom_input_from_arguments(&json!("echo hi")), "echo hi"); + assert_eq!( + custom_input_from_arguments(&json!({"cmd": "echo hi"})), + "echo hi" + ); + assert_eq!(custom_input_from_arguments(&json!(null)), ""); + // Two named arguments are ambiguous, so the JSON is preserved rather than + // one of them being picked. + let many = custom_input_from_arguments(&json!({"cmd": "ls", "dir": "/tmp"})); + assert!(many.contains("\"cmd\""), "{many}"); + assert!(many.contains("\"dir\""), "{many}"); + } + + #[test] + fn rewrites_a_buffered_function_call_into_a_custom_tool_call() { + let names = custom_tool_names(&extensions(&["exec"])); + let mut body = json!({ + "output": [{ + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "exec", + "arguments": {"input": "echo hi"} + }] + }); + + restore_custom_tool_calls(&mut body, &names, &mut CustomToolStreamState::default()); + + let item = &body["output"][0]; + assert_eq!(item["type"], "custom_tool_call"); + assert_eq!(item["input"], "echo hi"); + assert_eq!(item["call_id"], "call_1"); + assert!( + item.get("arguments").is_none(), + "arguments must not survive: {item}" + ); + } + + // A function tool keeps its own shape, or every tool call would arrive as a + // custom one. + #[test] + fn leaves_a_function_tool_alone() { + let names = custom_tool_names(&extensions(&["exec"])); + let mut body = json!({ + "output": [{"type": "function_call", "name": "search", "arguments": {"q": "x"}}] + }); + let before = body.clone(); + + restore_custom_tool_calls(&mut body, &names, &mut CustomToolStreamState::default()); + + assert_eq!(body, before); + } + + // Deltas name only the item id, so the rename depends on state carried from + // the `output_item.added` event earlier in the same stream. + #[test] + fn renames_the_argument_deltas_of_a_custom_call_only() { + let names = custom_tool_names(&extensions(&["exec"])); + let mut state = CustomToolStreamState::default(); + + let mut added = json!({ + "type": "response.output_item.added", + "item": {"type": "function_call", "id": "fc_1", "name": "exec", "arguments": {}} + }); + restore_custom_tool_calls(&mut added, &names, &mut state); + assert_eq!(added["item"]["type"], "custom_tool_call"); + assert_eq!(added["item"]["id"], "ctc_1"); + + let mut delta = json!({ + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "delta": "echo hi" + }); + restore_custom_tool_calls(&mut delta, &names, &mut state); + assert_eq!(delta["type"], "response.custom_tool_call_input.delta"); + assert_eq!(delta["delta"], "echo hi"); + // The delta must name the item by its new id, or the client cannot attach + // the input to the call it announced. + assert_eq!(delta["item_id"], "ctc_1"); + + // An item that was never announced as custom keeps the function events. + let mut other = json!({ + "type": "response.function_call_arguments.delta", + "item_id": "fc_2", + "delta": "{}" + }); + restore_custom_tool_calls(&mut other, &names, &mut state); + assert_eq!(other["type"], "response.function_call_arguments.delta"); + } + + // A strict Responses backend validates the item-id prefix per item type. Azure + // rejected a replayed `custom_tool_call` with + // Invalid 'input[8].id': 'fc_2'. Expected an ID that begins with 'ctc'. + // The client persists the item, so a wrong prefix fails the FOLLOWING request. + #[test] + fn re_prefixes_the_item_id_for_a_custom_call() { + assert_eq!(custom_call_item_id("fc_2"), "ctc_2"); + assert_eq!(custom_call_item_id("fc2"), "ctc_2"); + // An id the upstream already made a custom one is left untouched. + assert_eq!(custom_call_item_id("ctc_9"), "ctc_9"); + // An id with no recognizable prefix still comes back valid. + assert_eq!(custom_call_item_id("abc"), "ctc_abc"); + assert_eq!(custom_call_item_id("fc"), "ctc"); + } + + #[test] + fn does_nothing_without_recorded_custom_tools() { + let mut body = json!({ + "output": [{"type": "function_call", "name": "exec", "arguments": {"input": "x"}}] + }); + let before = body.clone(); + + restore_custom_tool_calls( + &mut body, + &custom_tool_names(&ProviderExtensions::default()), + &mut CustomToolStreamState::default(), + ); + + assert_eq!(body, before); + } +} diff --git a/crates/switchyard-translation/src/helpers.rs b/crates/switchyard-translation/src/helpers.rs index 106701b22..43bef57b2 100644 --- a/crates/switchyard-translation/src/helpers.rs +++ b/crates/switchyard-translation/src/helpers.rs @@ -84,6 +84,11 @@ pub fn encode_aggregated_response_with_extensions( &mut body, &crate::codex_namespaces::qualified_tool_origins(request_extensions), ); + crate::codex_tools::restore_custom_tool_calls( + &mut body, + &crate::codex_tools::custom_tool_names(request_extensions), + &mut crate::codex_tools::CustomToolStreamState::default(), + ); Ok(body) } @@ -127,6 +132,10 @@ pub fn encode_stream_with_extensions( request_extensions: &switchyard_protocol::ProviderExtensions, ) -> std::result::Result { let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions); + // One state for the whole stream: a delta names only its `item_id`, so the + // rewrite depends on the `output_item.added` event seen earlier. + let custom_names = crate::codex_tools::custom_tool_names(request_extensions); + let mut custom_state = crate::codex_tools::CustomToolStreamState::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. @@ -157,6 +166,11 @@ pub fn encode_stream_with_extensions( served_model_for_events.as_deref(), ); crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins); + crate::codex_tools::restore_custom_tool_calls( + &mut value, + &custom_names, + &mut custom_state, + ); yield value; } if state.errored { @@ -170,6 +184,11 @@ pub fn encode_stream_with_extensions( served_model_for_events.as_deref(), ); crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins); + crate::codex_tools::restore_custom_tool_calls( + &mut value, + &custom_names, + &mut custom_state, + ); yield value; } }; diff --git a/crates/switchyard-translation/src/lib.rs b/crates/switchyard-translation/src/lib.rs index bd5d5e060..4b80044f2 100644 --- a/crates/switchyard-translation/src/lib.rs +++ b/crates/switchyard-translation/src/lib.rs @@ -9,6 +9,7 @@ pub mod codecs; pub(crate) mod codex_namespaces; +pub(crate) mod codex_tools; pub mod diagnostic; pub mod engine; pub mod error; diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 56ae8d6ea..ddccd4081 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -2176,3 +2176,257 @@ fn anthropic_thinking_is_dropped_from_responses_input() -> TestResult { ); Ok(()) } + +// A Codex freeform (`custom`) tool must reach a chat upstream as a callable +// function. Before this it fell through to the id-keyed fallback, which finds no +// `id` and dropped the tool, so the model was told about a tool it had not been +// given and wrote the call as prose instead — the turn then executed nothing +// while reporting success. +#[test] +fn responses_custom_tool_reaches_openai_chat_as_a_callable_function() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-chat", + "input": [{"role": "user", "content": "list the files"}], + "tools": [ + {"type": "custom", "name": "exec", "description": "Run a shell command."}, + { + "type": "function", + "name": "lookup", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}} + } + ] + }); + + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &normalized_policy(), + )?; + + let tools = translated.body["tools"] + .as_array() + .expect("tools survive translation"); + assert_eq!(tools.len(), 2, "no tool may be dropped: {tools:?}"); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .collect(); + assert!(names.contains(&"exec"), "custom tool missing: {tools:?}"); + assert!( + names.contains(&"lookup"), + "function tool missing: {tools:?}" + ); + + // The freeform contract becomes one required string, the only shape a chat + // upstream can express. + let exec = tools + .iter() + .find(|tool| tool["function"]["name"] == "exec") + .expect("exec tool present"); + assert_eq!( + exec["function"]["parameters"]["properties"]["input"]["type"], + "string" + ); + assert_eq!(exec["function"]["parameters"]["required"][0], "input"); + Ok(()) +} + +// Codex replays its freeform calls as `custom_tool_call` items. Unhandled they +// reached the catch-all and became opaque *user* content, which is what taught the +// model to imitate the markup in prose rather than call the tool. +#[test] +fn responses_custom_tool_call_history_becomes_a_chat_tool_call() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-chat", + "input": [ + {"role": "user", "content": "list the files"}, + {"type": "custom_tool_call", "call_id": "call_1", "name": "exec", "input": "ls -l"}, + {"type": "custom_tool_call_output", "call_id": "call_1", "output": "a.txt"}, + {"role": "user", "content": "and now?"} + ], + "tools": [{"type": "custom", "name": "exec"}] + }); + + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &normalized_policy(), + )?; + + let messages = translated.body["messages"] + .as_array() + .expect("messages present"); + let assistant = messages + .iter() + .find(|message| message["role"] == "assistant") + .expect("the replayed call is an assistant tool call, not user content"); + let call = &assistant["tool_calls"][0]; + assert_eq!(call["function"]["name"], "exec"); + assert_eq!(call["id"], "call_1"); + // Spelled the way the tool is advertised on this wire. + let arguments: Value = serde_json::from_str( + call["function"]["arguments"] + .as_str() + .expect("arguments are a JSON string on the chat wire"), + )?; + assert_eq!(arguments["input"], "ls -l"); + + let result = messages + .iter() + .find(|message| message["role"] == "tool") + .expect("the output becomes a tool result"); + assert_eq!(result["tool_call_id"], "call_1"); + assert!( + !messages.iter().any(|message| message["role"] == "user" + && message["content"].to_string().contains("custom_tool_call")), + "a replayed call must never reappear as user content: {messages:?}" + ); + Ok(()) +} + +// A Responses-to-Responses hop must not rewrite the tool into a function, or a +// passthrough would silently change the contract Codex declared. +#[test] +fn responses_custom_tool_survives_a_same_format_hop_unchanged() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5.6", + "input": [{"role": "user", "content": "hi"}], + "tools": [{"type": "custom", "name": "exec", "description": "Run a shell command."}] + }); + + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &normalized_policy(), + )?; + + assert_eq!(translated.body["tools"][0]["type"], "custom"); + assert_eq!(translated.body["tools"][0]["name"], "exec"); + Ok(()) +} + +// Codex 0.146 declares its whole toolset in an `additional_tools` INPUT item and +// sends no top-level `tools` key at all. Undecoded that item reached the input +// catch-all and became opaque user content, so the upstream was offered zero tools +// while Codex's prompt still described them — the model wrote its calls as prose and +// the turn completed having executed nothing. +#[test] +fn responses_additional_tools_item_becomes_the_upstream_toolset() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "deepseek-chat", + "tool_choice": "auto", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Run a shell command.", + "format": {"type": "text"} + }, + { + "type": "function", + "name": "wait", + "description": "Wait for a background task.", + "parameters": {"type": "object", "properties": {}}, + "strict": false + }, + { + "type": "namespace", + "name": "collaboration", + "tools": [{ + "type": "function", + "name": "request_user_input", + "parameters": {"type": "object", "properties": {}} + }] + } + ] + }, + {"type": "message", "role": "user", "content": "list the files"} + ] + }); + + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &normalized_policy(), + )?; + + let tools = translated.body["tools"] + .as_array() + .expect("the additional_tools item must produce a toolset"); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .collect(); + assert!(names.contains(&"exec"), "custom tool missing: {names:?}"); + assert!(names.contains(&"wait"), "function tool missing: {names:?}"); + assert!( + names.iter().any(|name| name.contains("request_user_input")), + "namespaced tool missing: {names:?}" + ); + + // The declaration is not conversation: resending it as a message would repeat + // every schema as prose and invite the model to answer in kind. + let messages = translated.body["messages"] + .as_array() + .expect("messages present"); + for message in messages { + let text = message.to_string(); + assert!( + !text.contains("additional_tools"), + "the declaration leaked into the conversation: {message}" + ); + } + Ok(()) +} + +// A same-format hop must hand the upstream the shape the client sent, so the +// declaration goes back where it came from rather than moving to `tools`. +#[test] +fn responses_additional_tools_item_survives_a_same_format_hop() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-5.6-sol", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "custom", "name": "exec", "description": "Run it."}] + }, + {"type": "message", "role": "user", "content": "hi"} + ] + }); + + let translated = engine.translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiResponses, + &body, + &normalized_policy(), + )?; + + let item = &translated.body["input"][0]; + assert_eq!( + item["type"], "additional_tools", + "shape changed: {}", + translated.body + ); + assert_eq!(item["tools"][0]["type"], "custom"); + assert_eq!(item["tools"][0]["name"], "exec"); + assert!( + translated.body.get("tools").is_none(), + "a declaration that arrived in the input must not move to `tools`: {}", + translated.body + ); + Ok(()) +}