From e1b5e902c4268783af51831800d55c628bb096b4 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Mon, 9 Nov 2026 14:37:57 -0800 Subject: [PATCH 1/5] fix(translation): change Responses namespace separator Signed-off-by: David Gardner --- crates/libsy-llm-client/src/client.rs | 17 ++++++++--------- .../libsy/src/algorithms/util/tool_signals.rs | 13 ++++++++----- crates/protocol/src/codex_namespaces.rs | 4 ++-- .../switchyard-nemo-relay-plugin/src/runtime.rs | 4 ++-- crates/switchyard-server/tests/server.rs | 12 +++++++++--- .../src/codecs/responses/buffered.rs | 2 +- .../src/codex_namespaces.rs | 14 +++++++------- .../tests/response_translation.rs | 4 ++-- tests/test_translation.py | 2 +- tests/test_translation_stream.py | 2 +- 10 files changed, 41 insertions(+), 33 deletions(-) diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 7962f1ec5b..2d8d6357aa 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -16,6 +16,7 @@ use http::StatusCode; use reqwest::RequestBuilder; use reqwest::header::{HeaderMap, RETRY_AFTER}; use serde_json::{Map, Value, json}; +use switchyard_protocol::codex_namespaces::NAMESPACE_SEPARATOR; use switchyard_protocol::{ LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStream, LlmResponseStreamEvent, Metadata, ModelId, Request, Response, RoutedLlmClient, @@ -1168,8 +1169,6 @@ fn rejects_template_options(error: &LlmClientError) -> bool { .any(|phrase| message.contains(phrase))) } -const CODEX_NAMESPACE_SEPARATOR: &str = "__"; - /// Rewrites a Responses body into the shape OpenAI-compatible providers accept. /// /// Codex extends Responses with namespace containers and namespaced function calls, and @@ -1273,7 +1272,7 @@ fn qualify_responses_function_name_with_namespace( let Some(name) = object.get("name").and_then(Value::as_str) else { return; }; - let prefix = format!("{namespace}{CODEX_NAMESPACE_SEPARATOR}"); + let prefix = format!("{namespace}{NAMESPACE_SEPARATOR}"); if name.starts_with(&prefix) { return; } @@ -3971,7 +3970,7 @@ mod tests { .and(wiremock::matchers::body_partial_json(json!({ "tools": [{ "type": "function", - "function": {"name": "mcp__open_websearch__search"} + "function": {"name": "mcp__open_websearch_sy_priv_ns_search"} }] }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -3986,7 +3985,7 @@ mod tests { "id": "call_1", "type": "function", "function": { - "name": "mcp__open_websearch__search", + "name": "mcp__open_websearch_sy_priv_ns_search", "arguments": "{\"q\":\"rust\"}" } }] @@ -4049,7 +4048,7 @@ mod tests { "output": [{ "type": "function_call", "call_id": "call_1", - "name": "mcp__open_websearch__search", + "name": "mcp__open_websearch_sy_priv_ns_search", "arguments": "{\"q\":\"rust\"}" }], "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} @@ -4106,18 +4105,18 @@ mod tests { request_body["tools"], json!([{ "type": "function", - "name": "mcp__open_websearch__search", + "name": "mcp__open_websearch_sy_priv_ns_search", "description": "", "parameters": parameters }]) ); assert_eq!( request_body["tool_choice"], - json!({"type": "function", "name": "mcp__open_websearch__search"}) + json!({"type": "function", "name": "mcp__open_websearch_sy_priv_ns_search"}) ); assert_eq!( request_body["input"][0]["name"], - "mcp__open_websearch__search" + "mcp__open_websearch_sy_priv_ns_search" ); assert!(request_body["input"][0].get("namespace").is_none()); diff --git a/crates/libsy/src/algorithms/util/tool_signals.rs b/crates/libsy/src/algorithms/util/tool_signals.rs index a98c11b8ea..ba306c4d4c 100644 --- a/crates/libsy/src/algorithms/util/tool_signals.rs +++ b/crates/libsy/src/algorithms/util/tool_signals.rs @@ -1079,7 +1079,7 @@ fn extract_tool_signals_with_window_and_semantics( match block { _ if index < start => continue, ContentBlock::ToolCall(call) => { - // Responses namespaced tools arrive as `__`. + // Responses namespaced tools arrive as `_sy_priv_ns_`. let bare_name = namespaces .and_then(|namespaces| split_qualified_name(namespaces, &call.name)) .map(|(tool, _)| tool) @@ -3123,17 +3123,20 @@ mod tests { #[test] fn configured_tool_semantics_match_namespaced_and_mcp_tools() { // The Responses decoder flattens namespaced tools and records the mapping. - let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]); + let mut request = with_messages(vec![tc("mcp__billing_sy_priv_ns_send_payment_request")]); request.llm_request.extensions.fields.insert( TOOL_NAMESPACES_KEY.to_string(), - json!({"mcp__billing__send_payment_request": "mcp__billing"}), + json!({"mcp__billing_sy_priv_ns_send_payment_request": "mcp__billing"}), ); // Claude Code sends MCP tools flat, with no namespace mapping. let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]); - for request in [&request, &claude_request] { - for name in ["send_payment_request", "mcp__billing__send_payment_request"] { + for (request, full_name) in [ + (&request, "mcp__billing_sy_priv_ns_send_payment_request"), + (&claude_request, "mcp__billing__send_payment_request"), + ] { + for name in ["send_payment_request", full_name] { let semantics = ToolSemantics { mutate: vec![name.to_string()], ..Default::default() diff --git a/crates/protocol/src/codex_namespaces.rs b/crates/protocol/src/codex_namespaces.rs index e56064b872..000926c080 100644 --- a/crates/protocol/src/codex_namespaces.rs +++ b/crates/protocol/src/codex_namespaces.rs @@ -4,7 +4,7 @@ //! Reads the Codex tool namespaces that a Responses request decoder records. //! //! Codex groups tools into `namespace` containers. The Responses codec in -//! `switchyard-translation` flattens each child to `__` and +//! `switchyard-translation` flattens each child to `_sy_priv_ns_` and //! stores the mapping in the request's [`ProviderExtensions`]. Code that does not //! depend on the translation crate uses these helpers to recover the tool name. @@ -13,7 +13,7 @@ use serde_json::{Map, Value}; use crate::ProviderExtensions; /// Separator between a namespace and a tool name in a qualified wire name. -pub const NAMESPACE_SEPARATOR: &str = "__"; +pub const NAMESPACE_SEPARATOR: &str = "_sy_priv_ns_"; /// Request extension key holding the qualified-name to namespace mapping. /// diff --git a/crates/switchyard-nemo-relay-plugin/src/runtime.rs b/crates/switchyard-nemo-relay-plugin/src/runtime.rs index ae834353f7..315418aaaf 100644 --- a/crates/switchyard-nemo-relay-plugin/src/runtime.rs +++ b/crates/switchyard-nemo-relay-plugin/src/runtime.rs @@ -1267,7 +1267,7 @@ seed = 17 "id": "call_search", "type": "function", "function": { - "name": "mcp__docs__search", + "name": "mcp__docs_sy_priv_ns_search", "arguments": "{\"query\":\"routing\"}", }, }], @@ -1305,7 +1305,7 @@ seed = 17 async fn streaming_responses_restore_codex_tool_namespaces() { let server = MockServer::start().await; let sse = concat!( - "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_search\",\"type\":\"function\",\"function\":{\"name\":\"mcp__docs__search\",\"arguments\":\"{\\\"query\\\":\\\"routing\\\"}\"}}]},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_search\",\"type\":\"function\",\"function\":{\"name\":\"mcp__docs_sy_priv_ns_search\",\"arguments\":\"{\\\"query\\\":\\\"routing\\\"}\"}}]},\"finish_reason\":null}]}\n\n", "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", "data: [DONE]\n\n", ); diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index e6d7813255..e2433bf882 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -6149,8 +6149,14 @@ async fn responses_round_trips_codex_tool_namespaces() -> TestResult { .collect::>() }) .unwrap_or_default(); - assert_eq!(offered, vec!["mcp__a__search", "mcp__b__search"]); - assert_eq!(sent["tool_choice"]["function"]["name"], "mcp__b__search"); + assert_eq!( + offered, + vec!["mcp__a_sy_priv_ns_search", "mcp__b_sy_priv_ns_search"] + ); + assert_eq!( + sent["tool_choice"]["function"]["name"], + "mcp__b_sy_priv_ns_search" + ); let recorded = sent["messages"] .as_array() .and_then(|messages| { @@ -6159,7 +6165,7 @@ async fn responses_round_trips_codex_tool_namespaces() -> TestResult { .find_map(|message| message["tool_calls"][0]["function"]["name"].as_str()) }) .ok_or("no recorded tool call reached the upstream")?; - assert_eq!(recorded, "mcp__b__search"); + assert_eq!(recorded, "mcp__b_sy_priv_ns_search"); drop(calls); // The upstream answers with the qualified name; Codex must receive the tool diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 2a0acc6ca8..5941ec5300 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -1010,7 +1010,7 @@ fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { // // Codex groups tools into non-standard ``namespace`` containers that // OpenAI-compatible upstreams do not accept. Each child is exposed under a -// `__` name, and `namespaces` records the mapping so the +// `_sy_priv_ns_` name, and `namespaces` records the mapping so the // response can split it back apart. fn decode_responses_tools( value: Option<&Value>, diff --git a/crates/switchyard-translation/src/codex_namespaces.rs b/crates/switchyard-translation/src/codex_namespaces.rs index 8fe25502c9..b1a9ffb49f 100644 --- a/crates/switchyard-translation/src/codex_namespaces.rs +++ b/crates/switchyard-translation/src/codex_namespaces.rs @@ -10,7 +10,7 @@ //! `{"type": "function_call", "name": "search", "namespace": "mcp__docs"}`. //! //! OpenAI-compatible upstreams accept only flat `function` tools, so the request -//! codec flattens the containers and names each child `__`. +//! codec flattens the containers and names each child `_sy_priv_ns_`. //! Two tools differing only by namespace therefore stay distinct upstream, and //! the conversation history uses the same spelling so the transcript never //! teaches the model a name the upstream was not offered. @@ -178,22 +178,22 @@ mod tests { } // A tool name may itself contain the separator, so the namespace is matched - // as a prefix rather than by splitting on the first `__`. + // as a prefix rather than by splitting on the first occurrence of the separator. #[test] fn splits_a_qualified_name_back_apart() { let simple = extensions(&[("mcp__docs", "search")]); let simple = tool_namespaces(&simple).expect("mapping present"); assert_eq!( - split_qualified_name(simple, "mcp__docs__search"), + split_qualified_name(simple, "mcp__docs_sy_priv_ns_search"), Some(("search", "mcp__docs")) ); assert_eq!(split_qualified_name(simple, "unknown_tool"), None); - let nested = extensions(&[("mcp__docs", "fetch__raw")]); + let nested = extensions(&[("mcp__docs", "fetch_sy_priv_ns_raw")]); let nested = tool_namespaces(&nested).expect("mapping present"); assert_eq!( - split_qualified_name(nested, "mcp__docs__fetch__raw"), - Some(("fetch__raw", "mcp__docs")) + split_qualified_name(nested, "mcp__docs_sy_priv_ns_fetch_sy_priv_ns_raw"), + Some(("fetch_sy_priv_ns_raw", "mcp__docs")) ); } @@ -216,7 +216,7 @@ mod tests { fn resolves_namespaces_that_are_not_mcp_servers() { let origins = qualified_tool_origins(&extensions(&[("multi_agent_v1", "spawn_agent")])); let mut response = json!({ - "output": [{"type": "function_call", "name": "multi_agent_v1__spawn_agent"}] + "output": [{"type": "function_call", "name": "multi_agent_v1_sy_priv_ns_spawn_agent"}] }); restore_qualified_tool_names(&mut response, &origins); diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 0861d3dba1..fcd96b896d 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -864,7 +864,7 @@ fn openai_chat_response_with_tool_call_translates_to_responses_output_item() -> "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"} }, { "id": "call_2", "type": "function", - "function": {"name": "mcp__docs__search", "arguments": "{\"query\":\"streaming\"}"} + "function": {"name": "mcp__docs_sy_priv_ns_search", "arguments": "{\"query\":\"streaming\"}"} }, { "id": "call_3", "type": "function", "function": {"name": "exec", "arguments": "{\"input\":\"ls -la\"}"} @@ -890,7 +890,7 @@ fn openai_chat_response_with_tool_call_translates_to_responses_output_item() -> json!([ {"type": "function_call", "call_id": "call_1", "name": "search", "arguments": "{\"q\": \"rust\"}"}, - {"type": "function_call", "call_id": "call_2", "name": "mcp__docs__search", + {"type": "function_call", "call_id": "call_2", "name": "mcp__docs_sy_priv_ns_search", "arguments": "{\"query\": \"streaming\"}"}, {"type": "function_call", "call_id": "call_3", "name": "exec", "arguments": "{\"input\": \"ls -la\"}"} diff --git a/tests/test_translation.py b/tests/test_translation.py index dd903bf9a5..73b36cb3de 100644 --- a/tests/test_translation.py +++ b/tests/test_translation.py @@ -200,7 +200,7 @@ def test_response_ir_restores_request_tool_extensions(method) -> None: "id": "call-1", "type": "function", "function": { - "name": "mcp__docs__search", + "name": "mcp__docs_sy_priv_ns_search", "arguments": '{"query":"streaming"}', }, }, diff --git a/tests/test_translation_stream.py b/tests/test_translation_stream.py index e556ec6acd..8ecf96dd70 100644 --- a/tests/test_translation_stream.py +++ b/tests/test_translation_stream.py @@ -73,7 +73,7 @@ async def test_stream_translation_preserves_ir_and_restores_extensions(): "id": "call-1", "type": "function", "function": { - "name": "mcp__docs__search", + "name": "mcp__docs_sy_priv_ns_search", "arguments": '{"query":"streaming"}', }, } From 88ab444d4a7c2823379f8e53c2ab53491b748c15 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Mon, 9 Nov 2026 14:55:00 -0800 Subject: [PATCH 2/5] fix(translation): shorten Responses namespace separator Signed-off-by: David Gardner --- crates/libsy-llm-client/src/client.rs | 12 ++++++------ crates/libsy/src/algorithms/util/tool_signals.rs | 8 ++++---- crates/protocol/src/codex_namespaces.rs | 4 ++-- crates/switchyard-nemo-relay-plugin/src/runtime.rs | 4 ++-- crates/switchyard-server/tests/server.rs | 9 +++------ .../src/codecs/responses/buffered.rs | 2 +- .../switchyard-translation/src/codex_namespaces.rs | 12 ++++++------ .../tests/response_translation.rs | 4 ++-- tests/test_translation.py | 2 +- tests/test_translation_stream.py | 2 +- 10 files changed, 28 insertions(+), 31 deletions(-) diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 2d8d6357aa..fb441d4040 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -3970,7 +3970,7 @@ mod tests { .and(wiremock::matchers::body_partial_json(json!({ "tools": [{ "type": "function", - "function": {"name": "mcp__open_websearch_sy_priv_ns_search"} + "function": {"name": "mcp__open_websearch_sy_ns_search"} }] }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -3985,7 +3985,7 @@ mod tests { "id": "call_1", "type": "function", "function": { - "name": "mcp__open_websearch_sy_priv_ns_search", + "name": "mcp__open_websearch_sy_ns_search", "arguments": "{\"q\":\"rust\"}" } }] @@ -4048,7 +4048,7 @@ mod tests { "output": [{ "type": "function_call", "call_id": "call_1", - "name": "mcp__open_websearch_sy_priv_ns_search", + "name": "mcp__open_websearch_sy_ns_search", "arguments": "{\"q\":\"rust\"}" }], "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} @@ -4105,18 +4105,18 @@ mod tests { request_body["tools"], json!([{ "type": "function", - "name": "mcp__open_websearch_sy_priv_ns_search", + "name": "mcp__open_websearch_sy_ns_search", "description": "", "parameters": parameters }]) ); assert_eq!( request_body["tool_choice"], - json!({"type": "function", "name": "mcp__open_websearch_sy_priv_ns_search"}) + json!({"type": "function", "name": "mcp__open_websearch_sy_ns_search"}) ); assert_eq!( request_body["input"][0]["name"], - "mcp__open_websearch_sy_priv_ns_search" + "mcp__open_websearch_sy_ns_search" ); assert!(request_body["input"][0].get("namespace").is_none()); diff --git a/crates/libsy/src/algorithms/util/tool_signals.rs b/crates/libsy/src/algorithms/util/tool_signals.rs index ba306c4d4c..421dae8308 100644 --- a/crates/libsy/src/algorithms/util/tool_signals.rs +++ b/crates/libsy/src/algorithms/util/tool_signals.rs @@ -1079,7 +1079,7 @@ fn extract_tool_signals_with_window_and_semantics( match block { _ if index < start => continue, ContentBlock::ToolCall(call) => { - // Responses namespaced tools arrive as `_sy_priv_ns_`. + // Responses namespaced tools arrive as `_sy_ns_`. let bare_name = namespaces .and_then(|namespaces| split_qualified_name(namespaces, &call.name)) .map(|(tool, _)| tool) @@ -3123,17 +3123,17 @@ mod tests { #[test] fn configured_tool_semantics_match_namespaced_and_mcp_tools() { // The Responses decoder flattens namespaced tools and records the mapping. - let mut request = with_messages(vec![tc("mcp__billing_sy_priv_ns_send_payment_request")]); + let mut request = with_messages(vec![tc("mcp__billing_sy_ns_send_payment_request")]); request.llm_request.extensions.fields.insert( TOOL_NAMESPACES_KEY.to_string(), - json!({"mcp__billing_sy_priv_ns_send_payment_request": "mcp__billing"}), + json!({"mcp__billing_sy_ns_send_payment_request": "mcp__billing"}), ); // Claude Code sends MCP tools flat, with no namespace mapping. let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]); for (request, full_name) in [ - (&request, "mcp__billing_sy_priv_ns_send_payment_request"), + (&request, "mcp__billing_sy_ns_send_payment_request"), (&claude_request, "mcp__billing__send_payment_request"), ] { for name in ["send_payment_request", full_name] { diff --git a/crates/protocol/src/codex_namespaces.rs b/crates/protocol/src/codex_namespaces.rs index 000926c080..403f52b4c5 100644 --- a/crates/protocol/src/codex_namespaces.rs +++ b/crates/protocol/src/codex_namespaces.rs @@ -4,7 +4,7 @@ //! Reads the Codex tool namespaces that a Responses request decoder records. //! //! Codex groups tools into `namespace` containers. The Responses codec in -//! `switchyard-translation` flattens each child to `_sy_priv_ns_` and +//! `switchyard-translation` flattens each child to `_sy_ns_` and //! stores the mapping in the request's [`ProviderExtensions`]. Code that does not //! depend on the translation crate uses these helpers to recover the tool name. @@ -13,7 +13,7 @@ use serde_json::{Map, Value}; use crate::ProviderExtensions; /// Separator between a namespace and a tool name in a qualified wire name. -pub const NAMESPACE_SEPARATOR: &str = "_sy_priv_ns_"; +pub const NAMESPACE_SEPARATOR: &str = "_sy_ns_"; /// Request extension key holding the qualified-name to namespace mapping. /// diff --git a/crates/switchyard-nemo-relay-plugin/src/runtime.rs b/crates/switchyard-nemo-relay-plugin/src/runtime.rs index 315418aaaf..8ebae3749c 100644 --- a/crates/switchyard-nemo-relay-plugin/src/runtime.rs +++ b/crates/switchyard-nemo-relay-plugin/src/runtime.rs @@ -1267,7 +1267,7 @@ seed = 17 "id": "call_search", "type": "function", "function": { - "name": "mcp__docs_sy_priv_ns_search", + "name": "mcp__docs_sy_ns_search", "arguments": "{\"query\":\"routing\"}", }, }], @@ -1305,7 +1305,7 @@ seed = 17 async fn streaming_responses_restore_codex_tool_namespaces() { let server = MockServer::start().await; let sse = concat!( - "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_search\",\"type\":\"function\",\"function\":{\"name\":\"mcp__docs_sy_priv_ns_search\",\"arguments\":\"{\\\"query\\\":\\\"routing\\\"}\"}}]},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_search\",\"type\":\"function\",\"function\":{\"name\":\"mcp__docs_sy_ns_search\",\"arguments\":\"{\\\"query\\\":\\\"routing\\\"}\"}}]},\"finish_reason\":null}]}\n\n", "data: {\"id\":\"chatcmpl_tool\",\"object\":\"chat.completion.chunk\",\"model\":\"target/model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", "data: [DONE]\n\n", ); diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index e2433bf882..2fcba77cde 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -6149,13 +6149,10 @@ async fn responses_round_trips_codex_tool_namespaces() -> TestResult { .collect::>() }) .unwrap_or_default(); - assert_eq!( - offered, - vec!["mcp__a_sy_priv_ns_search", "mcp__b_sy_priv_ns_search"] - ); + assert_eq!(offered, vec!["mcp__a_sy_ns_search", "mcp__b_sy_ns_search"]); assert_eq!( sent["tool_choice"]["function"]["name"], - "mcp__b_sy_priv_ns_search" + "mcp__b_sy_ns_search" ); let recorded = sent["messages"] .as_array() @@ -6165,7 +6162,7 @@ async fn responses_round_trips_codex_tool_namespaces() -> TestResult { .find_map(|message| message["tool_calls"][0]["function"]["name"].as_str()) }) .ok_or("no recorded tool call reached the upstream")?; - assert_eq!(recorded, "mcp__b_sy_priv_ns_search"); + assert_eq!(recorded, "mcp__b_sy_ns_search"); drop(calls); // The upstream answers with the qualified name; Codex must receive the tool diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 5941ec5300..ed3b33b14e 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -1010,7 +1010,7 @@ fn request_role_from_responses(role: Option<&str>, path: &str) -> Result { // // Codex groups tools into non-standard ``namespace`` containers that // OpenAI-compatible upstreams do not accept. Each child is exposed under a -// `_sy_priv_ns_` name, and `namespaces` records the mapping so the +// `_sy_ns_` name, and `namespaces` records the mapping so the // response can split it back apart. fn decode_responses_tools( value: Option<&Value>, diff --git a/crates/switchyard-translation/src/codex_namespaces.rs b/crates/switchyard-translation/src/codex_namespaces.rs index b1a9ffb49f..6d51ccaf52 100644 --- a/crates/switchyard-translation/src/codex_namespaces.rs +++ b/crates/switchyard-translation/src/codex_namespaces.rs @@ -10,7 +10,7 @@ //! `{"type": "function_call", "name": "search", "namespace": "mcp__docs"}`. //! //! OpenAI-compatible upstreams accept only flat `function` tools, so the request -//! codec flattens the containers and names each child `_sy_priv_ns_`. +//! codec flattens the containers and names each child `_sy_ns_`. //! Two tools differing only by namespace therefore stay distinct upstream, and //! the conversation history uses the same spelling so the transcript never //! teaches the model a name the upstream was not offered. @@ -184,16 +184,16 @@ mod tests { let simple = extensions(&[("mcp__docs", "search")]); let simple = tool_namespaces(&simple).expect("mapping present"); assert_eq!( - split_qualified_name(simple, "mcp__docs_sy_priv_ns_search"), + split_qualified_name(simple, "mcp__docs_sy_ns_search"), Some(("search", "mcp__docs")) ); assert_eq!(split_qualified_name(simple, "unknown_tool"), None); - let nested = extensions(&[("mcp__docs", "fetch_sy_priv_ns_raw")]); + let nested = extensions(&[("mcp__docs", "fetch_sy_ns_raw")]); let nested = tool_namespaces(&nested).expect("mapping present"); assert_eq!( - split_qualified_name(nested, "mcp__docs_sy_priv_ns_fetch_sy_priv_ns_raw"), - Some(("fetch_sy_priv_ns_raw", "mcp__docs")) + split_qualified_name(nested, "mcp__docs_sy_ns_fetch_sy_ns_raw"), + Some(("fetch_sy_ns_raw", "mcp__docs")) ); } @@ -216,7 +216,7 @@ mod tests { fn resolves_namespaces_that_are_not_mcp_servers() { let origins = qualified_tool_origins(&extensions(&[("multi_agent_v1", "spawn_agent")])); let mut response = json!({ - "output": [{"type": "function_call", "name": "multi_agent_v1_sy_priv_ns_spawn_agent"}] + "output": [{"type": "function_call", "name": "multi_agent_v1_sy_ns_spawn_agent"}] }); restore_qualified_tool_names(&mut response, &origins); diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index fcd96b896d..71f076785b 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -864,7 +864,7 @@ fn openai_chat_response_with_tool_call_translates_to_responses_output_item() -> "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"} }, { "id": "call_2", "type": "function", - "function": {"name": "mcp__docs_sy_priv_ns_search", "arguments": "{\"query\":\"streaming\"}"} + "function": {"name": "mcp__docs_sy_ns_search", "arguments": "{\"query\":\"streaming\"}"} }, { "id": "call_3", "type": "function", "function": {"name": "exec", "arguments": "{\"input\":\"ls -la\"}"} @@ -890,7 +890,7 @@ fn openai_chat_response_with_tool_call_translates_to_responses_output_item() -> json!([ {"type": "function_call", "call_id": "call_1", "name": "search", "arguments": "{\"q\": \"rust\"}"}, - {"type": "function_call", "call_id": "call_2", "name": "mcp__docs_sy_priv_ns_search", + {"type": "function_call", "call_id": "call_2", "name": "mcp__docs_sy_ns_search", "arguments": "{\"query\": \"streaming\"}"}, {"type": "function_call", "call_id": "call_3", "name": "exec", "arguments": "{\"input\": \"ls -la\"}"} diff --git a/tests/test_translation.py b/tests/test_translation.py index 73b36cb3de..f20e32b59f 100644 --- a/tests/test_translation.py +++ b/tests/test_translation.py @@ -200,7 +200,7 @@ def test_response_ir_restores_request_tool_extensions(method) -> None: "id": "call-1", "type": "function", "function": { - "name": "mcp__docs_sy_priv_ns_search", + "name": "mcp__docs_sy_ns_search", "arguments": '{"query":"streaming"}', }, }, diff --git a/tests/test_translation_stream.py b/tests/test_translation_stream.py index 8ecf96dd70..2caa1e2caa 100644 --- a/tests/test_translation_stream.py +++ b/tests/test_translation_stream.py @@ -73,7 +73,7 @@ async def test_stream_translation_preserves_ir_and_restores_extensions(): "id": "call-1", "type": "function", "function": { - "name": "mcp__docs_sy_priv_ns_search", + "name": "mcp__docs_sy_ns_search", "arguments": '{"query":"streaming"}', }, } From 8fda5aba5f0bd5c64ad51bd957f544b6ac0cbba5 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Mon, 9 Nov 2026 15:24:29 -0800 Subject: [PATCH 3/5] fix(translation): reject oversized Chat tool names Signed-off-by: David Gardner --- .../src/codecs/openai_chat/buffered.rs | 16 ++- .../tests/request_translation.rs | 117 +++++++++++------- 2 files changed, 84 insertions(+), 49 deletions(-) diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index a6af1d8ebc..447c5b375c 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -31,6 +31,10 @@ use crate::util::{ reject_responses_native_input_item, stable_id, string_value, validate_request_capabilities, }; +// OpenAI's limit for tool names: +// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create +const MAX_TOOL_NAME_LENGTH: usize = 64; + /// Format codec for OpenAI Chat Completions payloads. pub struct OpenAiChatCodec; @@ -1205,12 +1209,22 @@ pub(crate) fn encode_openai_tools(tools: &[Tool]) -> Result { Ok(Value::Array( tools .iter() - .map(|tool| { + .enumerate() + .map(|(index, tool)| { let tool = tool.function().ok_or_else(|| { TranslationError::LossyConversion( "MCP and provider tool encoding is not implemented".into(), ) })?; + if tool.name.len() > MAX_TOOL_NAME_LENGTH { + return Err(TranslationError::InvalidValue { + path: format!("$.tools[{index}].function.name"), + message: format!( + "tool name {:?} exceeds the {MAX_TOOL_NAME_LENGTH}-character limit", + tool.name + ), + }); + } let mut function = json!({ "name": tool.name, "description": tool.description.clone().unwrap_or_default(), diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 72e78a4511..e0c3224c67 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1938,60 +1938,81 @@ fn anthropic_openai_compatible_extensions_are_preserved_for_openai_chat() -> Tes Ok(()) } -// Verifies Codex-style Responses tools translate into OpenAI Chat tool definitions. #[test] -fn responses_request_translates_codex_tool_shape_to_openai_chat() -> TestResult { - let engine = TranslationEngine::default(); - let body = json!({ - "model": "gpt-4", - "instructions": "Be brief.", - "input": "List files", - "max_output_tokens": 1024, - "reasoning": {"effort": "high"}, - "tools": [ - { - "type": "function", - "id": "exec_command", - "description": "Runs a command in a PTY.", - "inputSchema": { - "jsonSchema": { - "type": "object", - "properties": {"cmd": {"type": "string"}}, - "required": ["cmd"] - } - } - }, - {"id": "", "description": "", "inputSchema": {"jsonSchema": {}}} - ], - "tool_choice": "required" - }); +fn responses_tools_translate_to_chat_or_reject_oversized_names() -> TestResult { + use switchyard_protocol::codex_namespaces::NAMESPACE_SEPARATOR; - let output = engine - .translate_request( + let test_cases = [ + json!({"tool_name": "manage_repository_notification_subscription", "expect_failure": false}), + json!({"tool_name": "manage_repository_notification_subscriptions", "expect_failure": true}), + ]; + let engine = TranslationEngine::default(); + let namespace = "mcp__code_host"; + for case in test_cases { + let tool_name = case["tool_name"].as_str().unwrap(); + let body = json!({ + "model": "gpt-4", + "instructions": "Be brief.", + "input": "Manage repository notifications", + "max_output_tokens": 1024, + "reasoning": {"effort": "high"}, + "tools": [{ + "type": "namespace", + "name": namespace, + "tools": [ + { + "type": "function", + "id": tool_name, + "description": "Manage repository notification subscriptions.", + "inputSchema": { + "jsonSchema": { + "type": "object", + "properties": {"repository": {"type": "string"}}, + "required": ["repository"] + } + } + }, + {"id": "", "description": "", "inputSchema": {"jsonSchema": {}}} + ] + }], + "tool_choice": "required" + }); + let result = engine.translate_request( WireFormat::OpenAiResponses, WireFormat::OpenAiChat, &body, &TranslationPolicy::default(), - )? - .body; - - assert_eq!( - output["messages"][0], - json!({"role": "system", "content": "Be brief."}) - ); - assert_eq!( - output["messages"][1], - json!({"role": "user", "content": "List files"}) - ); - assert_eq!(output["max_completion_tokens"], 1024); - assert_eq!(output["reasoning_effort"], "high"); - assert_eq!(output["tool_choice"], "required"); - assert_eq!(output["tools"].as_array().unwrap().len(), 1); - assert_eq!(output["tools"][0]["function"]["name"], "exec_command"); - assert_eq!( - output["tools"][0]["function"]["parameters"]["required"], - json!(["cmd"]) - ); + ); + if case["expect_failure"] == true { + assert!(matches!( + result, + Err(TranslationError::InvalidValue { path, .. }) + if path == "$.tools[0].function.name" + )); + } else { + let output = result?.body; + assert_eq!( + output["messages"][0], + json!({"role": "system", "content": "Be brief."}) + ); + assert_eq!( + output["messages"][1], + json!({"role": "user", "content": "Manage repository notifications"}) + ); + assert_eq!(output["max_completion_tokens"], 1024); + assert_eq!(output["reasoning_effort"], "high"); + assert_eq!(output["tool_choice"], "required"); + assert_eq!(output["tools"].as_array().unwrap().len(), 1); + assert_eq!( + output["tools"][0]["function"]["name"], + format!("{namespace}{NAMESPACE_SEPARATOR}{tool_name}") + ); + assert_eq!( + output["tools"][0]["function"]["parameters"]["required"], + json!(["repository"]) + ); + } + } Ok(()) } From f17689e81eda9d1732f5fc3921c1680b2d768f82 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Mon, 9 Nov 2026 15:36:31 -0800 Subject: [PATCH 4/5] docs(stage): clarify namespaced tool matching Signed-off-by: David Gardner --- docs/routing_algorithms/stage_router_routing.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/routing_algorithms/stage_router_routing.md b/docs/routing_algorithms/stage_router_routing.md index b9b25077e4..9b7b96f42d 100644 --- a/docs/routing_algorithms/stage_router_routing.md +++ b/docs/routing_algorithms/stage_router_routing.md @@ -387,12 +387,17 @@ across categories, and unknown category keys are rejected when the route is loaded. Argument-aware wrapper tools, inferred semantics, and learned routing rules are outside this exact-name configuration. -MCP tools match by their bare tool name or by their full name. Claude Code sends -an MCP tool as `mcp____`, and Codex sends it with a separate -`namespace`. For both, `send_payment_request` matches the `send_payment_request` -tool on the `billing` MCP server, and so does -`mcp__billing__send_payment_request`. A server name that contains `__` needs the -full name. +Stage's custom `tool_semantics` rules can match a tool's full name or its bare +name. For both Claude Code and Codex, `send_payment_request` matches that tool +across namespaces. To restrict a rule to the `billing` MCP server, use +`mcp__billing__send_payment_request` for Claude Code or +`mcp__billing_sy_ns_send_payment_request` for Codex. Codex supplies a separate +namespace, which Switchyard joins to the tool name as +`_sy_ns_`. Claude Code supplies the full name directly as +`mcp____`; a server name containing `__` requires a full-name rule. + +Bare-name matching here only classifies tool activity for routing. It does not +assign a namespace to a tool call when translating a response. ### Optional: handoff notes From 0a1d5641820f68a15726294dfe9e02ece1846a23 Mon Sep 17 00:00:00 2001 From: David Gardner Date: Mon, 9 Nov 2026 15:58:16 -0800 Subject: [PATCH 5/5] test(typescript): update namespace separator fixtures Signed-off-by: David Gardner --- bindings/typescript/test/translation.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/typescript/test/translation.test.mjs b/bindings/typescript/test/translation.test.mjs index 2639629915..3d96b1c29b 100644 --- a/bindings/typescript/test/translation.test.mjs +++ b/bindings/typescript/test/translation.test.mjs @@ -56,7 +56,7 @@ test("response encoding and translation restore request tool extensions only whe const wireResponse = { id: "response-1", model: "example", choices: [{ message: { role: "assistant", tool_calls: [ - { id: "call-1", type: "function", function: { name: "mcp__docs__search", arguments: '{"query":"streaming"}' } }, + { id: "call-1", type: "function", function: { name: "mcp__docs_sy_ns_search", arguments: '{"query":"streaming"}' } }, ], }, finish_reason: "tool_calls" }], }; @@ -66,7 +66,7 @@ test("response encoding and translation restore request tool extensions only whe (...extensions) => engine.translateResponse(WireFormat.OPENAI_CHAT, WireFormat.OPENAI_RESPONSES, wireResponse, ...extensions), ]) { const plain = convert(); - assert.equal(plain.body.output[0].name, "mcp__docs__search"); + assert.equal(plain.body.output[0].name, "mcp__docs_sy_ns_search"); assert.equal(plain.body.output[0].namespace, undefined); const restored = convert(JSON.parse(JSON.stringify(request.extensions))); assert.deepEqual(restored.diagnostics, []);