diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index d6ca1191d..1157284d6 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -17,7 +17,8 @@ use switchyard_protocol::{ }; use switchyard_translation::{ WireFormat, decode_aggregated_response, decode_request, decode_stream, - encode_aggregated_response, encode_request, encode_stream, + encode_aggregated_response, encode_request, encode_stream, responses_tool_namespaces, + restore_responses_tool_namespaces, }; use tracing::Instrument; @@ -463,6 +464,7 @@ impl TranslatingLlmClient { model: Option<&str>, wire_format: WireFormat, ) -> Result { + let response_namespaces = responses_tool_namespaces(&raw_http_request, wire_format); let llm_request = decode_request(wire_format, &raw_http_request) .map_err(|error| LlmClientError::RequestTranslation(error.to_string()))?; // The model that serves the call — the rewrite target when the caller pinned @@ -490,14 +492,25 @@ impl TranslatingLlmClient { match response.llm_response { LlmResponse::Agg(agg) => { - let body = + let mut body = encode_aggregated_response(&agg, wire_format, served_model.as_deref()) .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?; + restore_responses_tool_namespaces(&mut body, &response_namespaces); Ok(RawResponse::Buffered(body)) } LlmResponse::Stream(chunks) => { let events = encode_stream(chunks, wire_format, served_model)?; - Ok(RawResponse::Stream(events)) + if response_namespaces.is_empty() { + Ok(RawResponse::Stream(events)) + } else { + let events = events.map(move |event| { + event.map(|mut value| { + restore_responses_tool_namespaces(&mut value, &response_namespaces); + value + }) + }); + Ok(RawResponse::Stream(Box::pin(events))) + } } } } @@ -1873,6 +1886,72 @@ mod tests { Ok(()) } + // A Codex MCP namespace is flattened for the Chat upstream, then restored + // on the Responses function call that comes back to Codex. + #[tokio::test] + async fn call_rewrite_model_raw_restores_codex_mcp_namespace() + -> std::result::Result<(), Box> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(wiremock::matchers::body_partial_json(json!({ + "tools": [{ + "type": "function", + "function": {"name": "search"} + }] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-1", + "model": "gpt", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"} + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + }))) + .mount(&server) + .await; + + let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?; + let raw = json!({ + "model": "client-facing", + "input": "Search for Rust.", + "tools": [{ + "type": "namespace", + "name": "mcp__open_websearch__", + "tools": [{ + "type": "function", + "name": "search", + "description": "Search the web", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}} + }] + }] + }); + + let RawResponse::Buffered(body) = client + .call_rewrite_model_raw(raw, None, Some("gpt"), WireFormat::OpenAiResponses) + .await? + else { + panic!("expected a buffered response"); + }; + + assert_eq!(body["output"][0]["type"], "function_call"); + assert_eq!(body["output"][0]["name"], "search"); + assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch__"); + // Arguments are parsed and re-serialized, so the spacing is normalized. + assert_eq!(body["output"][0]["arguments"], "{\"q\": \"rust\"}"); + Ok(()) + } + // Raw path, streaming: an inbound `stream: true` request yields an unframed stream // of OpenAI Chat chunk objects whose deltas reassemble the completion. #[tokio::test] diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 7ad32d81b..421d9ce62 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -42,7 +42,7 @@ use tokio::net::{TcpListener, TcpSocket}; use tokio::task; use tracing::{Instrument, Level}; -use switchyard_translation::{WireFormat, decode_request}; +use switchyard_translation::{WireFormat, decode_request, responses_tool_namespaces}; use crate::response::into_http_response; use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env}; @@ -686,6 +686,7 @@ async fn handle_llm_request( wire_format: WireFormat, routing_log_context: Option, ) -> Response { + let response_namespaces = responses_tool_namespaces(&body, wire_format); let cache_probe = state.track_cache_eligibility.then(|| prefix_probe(&body)); let (route, request) = match resolve_route(&state, metadata, body, wire_format) { Ok(resolved) => resolved, @@ -728,10 +729,11 @@ async fn handle_llm_request( }; let served_model = decision.map(|decision| decision.selected_model_id().to_string()); - let mut response = match into_http_response(response, wire_format, served_model) { - Ok(response) => response, - Err(error) => return server_error(error.to_string()), - }; + let mut response = + match into_http_response(response, wire_format, served_model, response_namespaces) { + Ok(response) => response, + Err(error) => return server_error(error.to_string()), + }; if let Some(decision) = decision { attach_routing_headers(&mut response, decision); } diff --git a/crates/switchyard-server/src/response.rs b/crates/switchyard-server/src/response.rs index 2149f7036..21101870d 100644 --- a/crates/switchyard-server/src/response.rs +++ b/crates/switchyard-server/src/response.rs @@ -3,12 +3,16 @@ //! Response encoding glue for libsy server endpoints. +use std::collections::HashMap; use std::error::Error; use axum::Json; use axum::response::{IntoResponse, Response as HttpResponse}; +use futures_util::StreamExt; use switchyard_protocol::{LlmResponse, Response as AlgorithmResponse}; -use switchyard_translation::{WireFormat, encode_aggregated_response, encode_stream}; +use switchyard_translation::{ + WireFormat, encode_aggregated_response, encode_stream, restore_responses_tool_namespaces, +}; use crate::sse::frame_stream; @@ -21,18 +25,24 @@ pub(crate) fn into_http_response( response: AlgorithmResponse, target_format: WireFormat, served_model: Option, + response_namespaces: HashMap, ) -> Result { match response.llm_response { - LlmResponse::Agg(response) => Ok(Json(encode_aggregated_response( - &response, - target_format, - served_model.as_deref(), - )?) - .into_response()), - LlmResponse::Stream(stream) => Ok(frame_stream( - encode_stream(stream, target_format, served_model)?, - target_format, - ) - .into_response()), + LlmResponse::Agg(response) => { + let mut body = + encode_aggregated_response(&response, target_format, served_model.as_deref())?; + restore_responses_tool_namespaces(&mut body, &response_namespaces); + Ok(Json(body).into_response()) + } + LlmResponse::Stream(stream) => { + let events = encode_stream(stream, target_format, served_model)?; + let events = events.map(move |event| { + event.map(|mut value| { + restore_responses_tool_namespaces(&mut value, &response_namespaces); + value + }) + }); + Ok(frame_stream(Box::pin(events), target_format).into_response()) + } } } diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 65bd5ab79..2c182f677 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -123,6 +123,21 @@ async fn upstream_chat( .into_response(); } if body["stream"].as_bool() == Some(true) { + // Streamed tool call, for the namespace-on-every-event assertions. + if body["messages"][0]["content"] == "mcp-tool-call" { + let events = [ + json!({"id": "chatcmpl-mcp", "model": model, "choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "search", "arguments": ""}}]}}]}).to_string(), + json!({"id": "chatcmpl-mcp", "model": model, "choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"q\":\"rust\"}"}}]}}]}).to_string(), + json!({"id": "chatcmpl-mcp", "model": model, "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}}).to_string(), + "[DONE]".to_string(), + ]; + let stream = futures_util::stream::iter( + events + .into_iter() + .map(|data| Ok::(Event::default().data(data))), + ); + return Sse::new(stream).into_response(); + } if body["messages"][0]["content"] == "stream-error" { let events = [ json!({"id": "chatcmpl-stream-error", "model": model, "choices": [{"index": 0, "delta": {"role": "assistant"}}]}).to_string(), @@ -153,6 +168,30 @@ async fn upstream_chat( return Sse::new(stream).into_response(); } + // Buffered tool call, the non-streaming counterpart of the branch above. + if body["messages"][0]["content"] == "mcp-tool-call" { + return Json(json!({ + "id": "chatcmpl-mcp", + "object": "chat.completion", + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"} + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7} + })) + .into_response(); + } + let custom_target_schema = body .pointer("/response_format/json_schema/schema/properties/decision/properties/target") .is_some(); @@ -2138,3 +2177,119 @@ async fn request_and_upstream_errors_use_the_inbound_wire_format() -> TestResult ); Ok(()) } + +// Returns every `data:` frame of an SSE body as JSON, skipping `[DONE]`. +fn sse_events(body: &str) -> Vec { + body.lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter(|data| *data != "[DONE]") + .filter_map(|data| serde_json::from_str(data).ok()) + .collect() +} + +// The Codex request shape: MCP tools wrapped in a `namespace` container. +fn codex_mcp_responses_request(stream: bool) -> Value { + json!({ + "model": ROUTE_MODEL, + "input": "mcp-tool-call", + "stream": stream, + "tools": [{ + "type": "namespace", + "name": "mcp__open_websearch__", + "description": "Web search MCP tools", + "tools": [{ + "type": "function", + "name": "search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"] + } + }] + }] + }) +} + +// The container is flattened for a Chat-only upstream, and the namespace is +// restored on the returned function call. +#[tokio::test] +async fn responses_buffered_restores_codex_mcp_namespace() -> TestResult { + const MODEL: &str = "model/mcp-buffered"; + let (upstream, app) = test_app(&[(ROUTE_MODEL, &[MODEL])]).await?; + + let response = send( + &app, + "POST", + "/v1/responses", + Some(codex_mcp_responses_request(false)), + ) + .await?; + + assert_eq!(response.status, StatusCode::OK); + let body = response.json()?; + assert_eq!(body["output"][0]["type"], "function_call"); + assert_eq!(body["output"][0]["name"], "search"); + assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch__"); + + // The upstream must never see the `namespace` container itself. + let calls = upstream.calls.lock().await; + let tools = calls[0]["tools"] + .as_array() + .ok_or("upstream received no tools")?; + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["type"], "function"); + assert_eq!(tools[0]["function"]["name"], "search"); + assert!( + calls[0]["tools"][0].get("namespace").is_none(), + "namespace container leaked upstream" + ); + Ok(()) +} + +// The namespace has to survive on every output-item event, not only on the +// terminal aggregate. +#[tokio::test] +async fn responses_stream_restores_codex_mcp_namespace() -> TestResult { + const MODEL: &str = "model/mcp-stream"; + let (_upstream, app) = test_app(&[(ROUTE_MODEL, &[MODEL])]).await?; + + let response = send( + &app, + "POST", + "/v1/responses", + Some(codex_mcp_responses_request(true)), + ) + .await?; + + assert_eq!(response.status, StatusCode::OK); + let events = sse_events(response.text()?); + + let namespace_of = |event_type: &str| -> Option { + events + .iter() + .find(|event| event["type"] == event_type) + .map(|event| event["item"]["namespace"].clone()) + }; + assert_eq!( + namespace_of("response.output_item.added"), + Some(json!("mcp__open_websearch__")), + "namespace missing from response.output_item.added" + ); + assert_eq!( + namespace_of("response.output_item.done"), + Some(json!("mcp__open_websearch__")), + "namespace missing from response.output_item.done" + ); + + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .ok_or("stream produced no response.completed event")?; + assert_eq!( + completed["response"]["output"][0]["namespace"], "mcp__open_websearch__", + "namespace missing from the response.completed aggregate" + ); + assert_eq!(completed["response"]["output"][0]["name"], "search"); + Ok(()) +} diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 5d3b9e888..3c7b402c2 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -709,15 +709,24 @@ fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { } // Decodes Responses tool shapes, including Codex-style tool entries. +// +// Codex groups each MCP server's tools in a non-standard ``namespace`` +// container that OpenAI-compatible upstreams do not accept, so the children are +// exposed under their original names. fn decode_responses_tools(value: Option<&Value>) -> Vec { let Some(tools) = value.and_then(Value::as_array) else { return Vec::new(); }; let mut out = Vec::new(); + // Decoded first so a flattened child sharing one of these names loses the + // tie below. for tool in tools { let Some(tool) = tool.as_object() else { continue; }; + if tool.get("type").and_then(Value::as_str) == Some("namespace") { + continue; + } 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) @@ -747,6 +756,21 @@ fn decode_responses_tools(value: Option<&Value>) -> Vec { push_responses_id_tool(&mut out, tool); } } + // Flattening drops the container, so children distinct only by namespace + // collide into one name. Keep the first claim on each name. + for tool in tools { + let Some(tool) = tool.as_object() else { + continue; + }; + if tool.get("type").and_then(Value::as_str) != Some("namespace") { + continue; + } + for child in decode_responses_tools(tool.get("tools")) { + if !out.iter().any(|existing| existing.name == child.name) { + out.push(child); + } + } + } out } diff --git a/crates/switchyard-translation/src/codex_namespaces.rs b/crates/switchyard-translation/src/codex_namespaces.rs new file mode 100644 index 000000000..d7dbf2656 --- /dev/null +++ b/crates/switchyard-translation/src/codex_namespaces.rs @@ -0,0 +1,310 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Codex MCP namespace preservation across a Responses/Chat translation. +//! +//! Codex groups each MCP server's tools in a non-standard ``namespace`` tool +//! container and expects the namespace back on the function call it receives: +//! `{"type": "function_call", "name": "search", "namespace": "mcp__docs__"}`. +//! OpenAI-compatible upstreams accept only flat `function` tools, so the request +//! codec flattens the container and the namespace is lost. +//! +//! Capturing the child-to-namespace mapping from the request lets the response +//! path put it back. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::WireFormat; + +/// Map every unambiguous Responses function tool to its Codex MCP namespace. +/// +/// Returns an empty map for any wire format other than +/// [`WireFormat::OpenAiResponses`], for a request without `tools`, and for any +/// name that cannot be attributed to exactly one namespace. Pair with +/// [`restore_responses_tool_namespaces`] on the response. +pub fn responses_tool_namespaces(body: &Value, wire_format: WireFormat) -> HashMap { + if wire_format != WireFormat::OpenAiResponses { + return HashMap::new(); + } + let Some(tools) = body.get("tools").and_then(Value::as_array) else { + return HashMap::new(); + }; + let mut namespaces = HashMap::new(); + collect_responses_tool_namespaces(tools, None, &mut namespaces); + namespaces + .into_iter() + .filter_map(|(name, namespace)| namespace.map(|namespace| (name, namespace))) + .collect() +} + +// A None entry marks a name that cannot be attributed to one namespace, which +// leaves the corresponding call flat. +fn collect_responses_tool_namespaces( + tools: &[Value], + parent_namespace: Option<&str>, + namespaces: &mut HashMap>, +) { + for tool in tools { + let Some(tool) = tool.as_object() else { + continue; + }; + if tool.get("type").and_then(Value::as_str) == Some("namespace") { + // An unnamed container carries nothing to dispatch on. + let namespace = tool + .get("name") + .and_then(Value::as_str) + .filter(|namespace| !namespace.is_empty()); + if let Some(children) = tool.get("tools").and_then(Value::as_array) { + collect_responses_tool_namespaces(children, namespace, namespaces); + } + continue; + } + let name = tool + .get("function") + .and_then(Value::as_object) + .and_then(|function| function.get("name")) + .or_else(|| tool.get("name")) + .or_else(|| tool.get("id")) + .and_then(Value::as_str) + .filter(|name| !name.is_empty()); + let Some(name) = name else { + continue; + }; + let Some(namespace) = parent_namespace else { + // A tool outside any namespace shares one flat name space with the + // flattened children, so a child of the same name cannot be told + // apart from it. + namespaces.insert(name.to_string(), None); + continue; + }; + match namespaces.get(name) { + None => { + namespaces.insert(name.to_string(), Some(namespace.to_string())); + } + Some(Some(existing)) if existing != namespace => { + namespaces.insert(name.to_string(), None); + } + Some(_) => {} + } + } +} + +/// Re-attach Codex MCP namespaces to every `function_call` in a response. +/// +/// Walks the whole value, covering a buffered body and each streaming event, +/// where the item is nested under `item` (`response.output_item.added` / +/// `.done`) or `response.output` (`response.completed`). An existing +/// `namespace` is never overwritten; a name absent from `namespaces` stays flat. +pub fn restore_responses_tool_namespaces(body: &mut Value, namespaces: &HashMap) { + if namespaces.is_empty() { + return; + } + match body { + Value::Array(values) => { + for value in values { + restore_responses_tool_namespaces(value, namespaces); + } + } + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("function_call") + && let Some(name) = object.get("name").and_then(Value::as_str) + && let Some(namespace) = namespaces.get(name) + { + object + .entry("namespace".to_string()) + .or_insert_with(|| Value::String(namespace.clone())); + } + for value in object.values_mut() { + restore_responses_tool_namespaces(value, namespaces); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{WireFormat, responses_tool_namespaces, restore_responses_tool_namespaces}; + + fn websearch_request() -> serde_json::Value { + json!({ + "tools": [{ + "type": "namespace", + "name": "mcp__open_websearch__", + "tools": [{ + "type": "function", + "name": "search", + "parameters": {"type": "object"} + }] + }] + }) + } + + #[test] + fn restores_codex_mcp_namespace_on_responses_function_call() { + let namespaces = + responses_tool_namespaces(&websearch_request(), WireFormat::OpenAiResponses); + let mut response = json!({ + "output": [{ + "type": "function_call", + "name": "search", + "arguments": "{\"q\":\"Rust\"}" + }] + }); + + restore_responses_tool_namespaces(&mut response, &namespaces); + + assert_eq!(response["output"][0]["namespace"], "mcp__open_websearch__"); + } + + // Streaming events nest the item one level deeper than a buffered body. + #[test] + fn restores_codex_mcp_namespace_on_nested_streaming_items() { + let namespaces = + responses_tool_namespaces(&websearch_request(), WireFormat::OpenAiResponses); + let mut added = json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": {"type": "function_call", "name": "search", "arguments": ""} + }); + let mut completed = json!({ + "type": "response.completed", + "response": { + "output": [{"type": "function_call", "name": "search", "arguments": "{}"}] + } + }); + + restore_responses_tool_namespaces(&mut added, &namespaces); + restore_responses_tool_namespaces(&mut completed, &namespaces); + + assert_eq!(added["item"]["namespace"], "mcp__open_websearch__"); + assert_eq!( + completed["response"]["output"][0]["namespace"], + "mcp__open_websearch__" + ); + } + + // A leaf name colliding with a top-level tool is equally ambiguous: stamping + // it would send that tool's calls to the MCP server. + #[test] + fn skips_names_shared_with_a_top_level_tool() { + let namespaced_first = json!({ + "tools": [ + { + "type": "namespace", + "name": "mcp__fs__", + "tools": [{"type": "function", "name": "read_file"}] + }, + {"type": "function", "name": "read_file", "parameters": {}} + ] + }); + let top_level_first = json!({ + "tools": [ + {"type": "function", "name": "read_file", "parameters": {}}, + { + "type": "namespace", + "name": "mcp__fs__", + "tools": [{"type": "function", "name": "read_file"}] + } + ] + }); + + assert!( + responses_tool_namespaces(&namespaced_first, WireFormat::OpenAiResponses).is_empty() + ); + assert!( + responses_tool_namespaces(&top_level_first, WireFormat::OpenAiResponses).is_empty() + ); + } + + #[test] + fn skips_an_empty_namespace_name() { + let request = json!({ + "tools": [{ + "type": "namespace", + "name": "", + "tools": [{"type": "function", "name": "search"}] + }] + }); + + assert!(responses_tool_namespaces(&request, WireFormat::OpenAiResponses).is_empty()); + } + + // Both the collector and the flattener recurse, so a leaf is attributed to + // the innermost container that names it. + #[test] + fn attributes_a_nested_leaf_to_its_innermost_namespace() { + let request = json!({ + "tools": [{ + "type": "namespace", + "name": "mcp__outer__", + "tools": [ + { + "type": "namespace", + "name": "mcp__inner__", + "tools": [{"type": "function", "name": "stat_file"}] + }, + {"type": "function", "name": "list_files"} + ] + }] + }); + + let namespaces = responses_tool_namespaces(&request, WireFormat::OpenAiResponses); + + assert_eq!( + namespaces.get("stat_file").map(String::as_str), + Some("mcp__inner__") + ); + assert_eq!( + namespaces.get("list_files").map(String::as_str), + Some("mcp__outer__") + ); + } + + // The same name under two namespaces cannot be told apart once flat. + #[test] + fn skips_ambiguous_codex_mcp_tool_names() { + let request = json!({ + "tools": [ + { + "type": "namespace", + "name": "mcp__first__", + "tools": [{"type": "function", "name": "search"}] + }, + { + "type": "namespace", + "name": "mcp__second__", + "tools": [{"type": "function", "name": "search"}] + } + ] + }); + + assert!(responses_tool_namespaces(&request, WireFormat::OpenAiResponses).is_empty()); + } + + #[test] + fn preserves_an_upstream_supplied_namespace() { + let namespaces = + responses_tool_namespaces(&websearch_request(), WireFormat::OpenAiResponses); + let mut response = json!({ + "output": [{ + "type": "function_call", + "name": "search", + "namespace": "mcp__upstream__" + }] + }); + + restore_responses_tool_namespaces(&mut response, &namespaces); + + assert_eq!(response["output"][0]["namespace"], "mcp__upstream__"); + } + + #[test] + fn ignores_non_responses_wire_formats() { + assert!(responses_tool_namespaces(&websearch_request(), WireFormat::OpenAiChat).is_empty()); + } +} diff --git a/crates/switchyard-translation/src/lib.rs b/crates/switchyard-translation/src/lib.rs index d07a3497d..47db3620f 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 mod codex_namespaces; pub mod diagnostic; pub mod engine; pub mod error; @@ -22,6 +23,7 @@ pub use switchyard_protocol::stream::{ }; pub use switchyard_protocol::{format, llm}; +pub use codex_namespaces::{responses_tool_namespaces, restore_responses_tool_namespaces}; pub use diagnostic::*; pub use engine::*; pub use error::*; diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index bfcf78625..a82d2cd55 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -449,6 +449,132 @@ fn responses_request_translates_codex_tool_shape_to_openai_chat() -> TestResult Ok(()) } +// Verifies Codex MCP namespace containers flatten to plain functions for a +// Chat-only upstream. +#[test] +fn responses_request_flattens_codex_mcp_namespace_tools() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-4", + "input": "List files", + "tools": [{ + "type": "namespace", + "name": "mcp__filesystem", + "description": "Filesystem MCP tools", + "tools": [ + { + "type": "namespace", + "name": "mcp__filesystem__nested", + "tools": [{ + "type": "function", + "name": "stat_file", + "parameters": {"type": "object"} + }] + }, + { + "type": "function", + "name": "list_files", + "description": "List files in a directory", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + } + } + ] + }] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + // A nested container flattens too, so both leaves arrive as plain functions. + let names = output["tools"] + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .collect::>() + }) + .unwrap_or_default(); + assert_eq!(names, vec!["list_files", "stat_file"]); + assert_eq!(output["tools"][0]["type"], "function"); + assert_eq!( + output["tools"][0]["function"]["parameters"]["required"], + json!(["path"]) + ); + Ok(()) +} + +// A child colliding with a tool outside the container must not reach the +// upstream as a second tool of the same name. +#[test] +fn responses_request_drops_namespace_children_colliding_with_top_level_tools() -> TestResult { + let engine = TranslationEngine::default(); + let body = json!({ + "model": "gpt-4", + "input": "Read a file", + "tools": [ + { + "type": "function", + "name": "read_file", + "description": "Codex builtin", + "parameters": {"type": "object", "properties": {}} + }, + { + "type": "namespace", + "name": "mcp__filesystem", + "tools": [ + { + "type": "function", + "name": "read_file", + "description": "MCP tool of the same name", + "parameters": {"type": "object", "properties": {}} + }, + { + "type": "function", + "name": "list_files", + "description": "MCP tool with a distinct name", + "parameters": {"type": "object", "properties": {}} + } + ] + } + ] + }); + + let output = engine + .translate_request( + WireFormat::OpenAiResponses, + WireFormat::OpenAiChat, + &body, + &TranslationPolicy::default(), + )? + .body; + + let names = output["tools"] + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool["function"]["name"].as_str()) + .collect::>() + }) + .unwrap_or_default(); + assert_eq!(names, vec!["read_file", "list_files"]); + assert_eq!( + output["tools"][0]["function"]["description"], + "Codex builtin" + ); + Ok(()) +} + // Verifies Python-style Responses tool definitions translate into OpenAI Chat tools. #[test] fn responses_request_translates_python_compatible_tool_shape_to_openai_chat() -> TestResult {