Skip to content

Commit 2f5b0f4

Browse files
committed
fix: preserve Codex MCP namespaces through translation
Signed-off-by: Brian Grinstead <briangrinstead@gmail.com>
1 parent 58f355a commit 2f5b0f4

8 files changed

Lines changed: 728 additions & 20 deletions

File tree

crates/libsy-llm-client/src/client.rs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use switchyard_protocol::{
1717
};
1818
use switchyard_translation::{
1919
WireFormat, decode_aggregated_response, decode_request, decode_stream,
20-
encode_aggregated_response, encode_request, encode_stream,
20+
encode_aggregated_response, encode_request, encode_stream, responses_tool_namespaces,
21+
restore_responses_tool_namespaces,
2122
};
2223
use tracing::Instrument;
2324

@@ -463,6 +464,7 @@ impl TranslatingLlmClient {
463464
model: Option<&str>,
464465
wire_format: WireFormat,
465466
) -> Result<RawResponse> {
467+
let response_namespaces = responses_tool_namespaces(&raw_http_request, wire_format);
466468
let llm_request = decode_request(wire_format, &raw_http_request)
467469
.map_err(|error| LlmClientError::RequestTranslation(error.to_string()))?;
468470
// The model that serves the call — the rewrite target when the caller pinned
@@ -490,14 +492,25 @@ impl TranslatingLlmClient {
490492

491493
match response.llm_response {
492494
LlmResponse::Agg(agg) => {
493-
let body =
495+
let mut body =
494496
encode_aggregated_response(&agg, wire_format, served_model.as_deref())
495497
.map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
498+
restore_responses_tool_namespaces(&mut body, &response_namespaces);
496499
Ok(RawResponse::Buffered(body))
497500
}
498501
LlmResponse::Stream(chunks) => {
499502
let events = encode_stream(chunks, wire_format, served_model)?;
500-
Ok(RawResponse::Stream(events))
503+
if response_namespaces.is_empty() {
504+
Ok(RawResponse::Stream(events))
505+
} else {
506+
let events = events.map(move |event| {
507+
event.map(|mut value| {
508+
restore_responses_tool_namespaces(&mut value, &response_namespaces);
509+
value
510+
})
511+
});
512+
Ok(RawResponse::Stream(Box::pin(events)))
513+
}
501514
}
502515
}
503516
}
@@ -1873,6 +1886,72 @@ mod tests {
18731886
Ok(())
18741887
}
18751888

1889+
// A Codex MCP namespace is flattened for the Chat upstream, then restored
1890+
// on the Responses function call that comes back to Codex.
1891+
#[tokio::test]
1892+
async fn call_rewrite_model_raw_restores_codex_mcp_namespace()
1893+
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1894+
let server = MockServer::start().await;
1895+
Mock::given(method("POST"))
1896+
.and(path("/v1/chat/completions"))
1897+
.and(wiremock::matchers::body_partial_json(json!({
1898+
"tools": [{
1899+
"type": "function",
1900+
"function": {"name": "search"}
1901+
}]
1902+
})))
1903+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1904+
"id": "chatcmpl-1",
1905+
"model": "gpt",
1906+
"choices": [{
1907+
"index": 0,
1908+
"message": {
1909+
"role": "assistant",
1910+
"content": null,
1911+
"tool_calls": [{
1912+
"id": "call_1",
1913+
"type": "function",
1914+
"function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}
1915+
}]
1916+
},
1917+
"finish_reason": "tool_calls"
1918+
}],
1919+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
1920+
})))
1921+
.mount(&server)
1922+
.await;
1923+
1924+
let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
1925+
let raw = json!({
1926+
"model": "client-facing",
1927+
"input": "Search for Rust.",
1928+
"tools": [{
1929+
"type": "namespace",
1930+
"name": "mcp__open_websearch__",
1931+
"tools": [{
1932+
"type": "function",
1933+
"name": "search",
1934+
"description": "Search the web",
1935+
"parameters": {"type": "object", "properties": {"q": {"type": "string"}}}
1936+
}]
1937+
}]
1938+
});
1939+
1940+
let RawResponse::Buffered(body) = client
1941+
.call_rewrite_model_raw(raw, None, Some("gpt"), WireFormat::OpenAiResponses)
1942+
.await?
1943+
else {
1944+
panic!("expected a buffered response");
1945+
};
1946+
1947+
assert_eq!(body["output"][0]["type"], "function_call");
1948+
assert_eq!(body["output"][0]["name"], "search");
1949+
assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch__");
1950+
// Arguments are parsed and re-serialized, so the spacing is normalized.
1951+
assert_eq!(body["output"][0]["arguments"], "{\"q\": \"rust\"}");
1952+
Ok(())
1953+
}
1954+
18761955
// Raw path, streaming: an inbound `stream: true` request yields an unframed stream
18771956
// of OpenAI Chat chunk objects whose deltas reassemble the completion.
18781957
#[tokio::test]

crates/switchyard-server/src/lib.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use tokio::net::{TcpListener, TcpSocket};
4242
use tokio::task;
4343
use tracing::{Instrument, Level};
4444

45-
use switchyard_translation::{WireFormat, decode_request};
45+
use switchyard_translation::{WireFormat, decode_request, responses_tool_namespaces};
4646

4747
use crate::response::into_http_response;
4848
use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env};
@@ -686,6 +686,7 @@ async fn handle_llm_request(
686686
wire_format: WireFormat,
687687
routing_log_context: Option<routing_log::RoutingLogContext>,
688688
) -> Response {
689+
let response_namespaces = responses_tool_namespaces(&body, wire_format);
689690
let cache_probe = state.track_cache_eligibility.then(|| prefix_probe(&body));
690691
let (route, request) = match resolve_route(&state, metadata, body, wire_format) {
691692
Ok(resolved) => resolved,
@@ -728,10 +729,11 @@ async fn handle_llm_request(
728729
};
729730

730731
let served_model = decision.map(|decision| decision.selected_model_id().to_string());
731-
let mut response = match into_http_response(response, wire_format, served_model) {
732-
Ok(response) => response,
733-
Err(error) => return server_error(error.to_string()),
734-
};
732+
let mut response =
733+
match into_http_response(response, wire_format, served_model, response_namespaces) {
734+
Ok(response) => response,
735+
Err(error) => return server_error(error.to_string()),
736+
};
735737
if let Some(decision) = decision {
736738
attach_routing_headers(&mut response, decision);
737739
}

crates/switchyard-server/src/response.rs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33

44
//! Response encoding glue for libsy server endpoints.
55
6+
use std::collections::HashMap;
67
use std::error::Error;
78

89
use axum::Json;
910
use axum::response::{IntoResponse, Response as HttpResponse};
11+
use futures_util::StreamExt;
1012
use switchyard_protocol::{LlmResponse, Response as AlgorithmResponse};
11-
use switchyard_translation::{WireFormat, encode_aggregated_response, encode_stream};
13+
use switchyard_translation::{
14+
WireFormat, encode_aggregated_response, encode_stream, restore_responses_tool_namespaces,
15+
};
1216

1317
use crate::sse::frame_stream;
1418

@@ -21,18 +25,24 @@ pub(crate) fn into_http_response(
2125
response: AlgorithmResponse,
2226
target_format: WireFormat,
2327
served_model: Option<String>,
28+
response_namespaces: HashMap<String, String>,
2429
) -> Result<HttpResponse, BoxError> {
2530
match response.llm_response {
26-
LlmResponse::Agg(response) => Ok(Json(encode_aggregated_response(
27-
&response,
28-
target_format,
29-
served_model.as_deref(),
30-
)?)
31-
.into_response()),
32-
LlmResponse::Stream(stream) => Ok(frame_stream(
33-
encode_stream(stream, target_format, served_model)?,
34-
target_format,
35-
)
36-
.into_response()),
31+
LlmResponse::Agg(response) => {
32+
let mut body =
33+
encode_aggregated_response(&response, target_format, served_model.as_deref())?;
34+
restore_responses_tool_namespaces(&mut body, &response_namespaces);
35+
Ok(Json(body).into_response())
36+
}
37+
LlmResponse::Stream(stream) => {
38+
let events = encode_stream(stream, target_format, served_model)?;
39+
let events = events.map(move |event| {
40+
event.map(|mut value| {
41+
restore_responses_tool_namespaces(&mut value, &response_namespaces);
42+
value
43+
})
44+
});
45+
Ok(frame_stream(Box::pin(events), target_format).into_response())
46+
}
3747
}
3848
}

crates/switchyard-server/tests/server.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ async fn upstream_chat(
123123
.into_response();
124124
}
125125
if body["stream"].as_bool() == Some(true) {
126+
// Streamed tool call, for the namespace-on-every-event assertions.
127+
if body["messages"][0]["content"] == "mcp-tool-call" {
128+
let events = [
129+
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(),
130+
json!({"id": "chatcmpl-mcp", "model": model, "choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"q\":\"rust\"}"}}]}}]}).to_string(),
131+
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(),
132+
"[DONE]".to_string(),
133+
];
134+
let stream = futures_util::stream::iter(
135+
events
136+
.into_iter()
137+
.map(|data| Ok::<Event, Infallible>(Event::default().data(data))),
138+
);
139+
return Sse::new(stream).into_response();
140+
}
126141
if body["messages"][0]["content"] == "stream-error" {
127142
let events = [
128143
json!({"id": "chatcmpl-stream-error", "model": model, "choices": [{"index": 0, "delta": {"role": "assistant"}}]}).to_string(),
@@ -153,6 +168,30 @@ async fn upstream_chat(
153168
return Sse::new(stream).into_response();
154169
}
155170

171+
// Buffered tool call, the non-streaming counterpart of the branch above.
172+
if body["messages"][0]["content"] == "mcp-tool-call" {
173+
return Json(json!({
174+
"id": "chatcmpl-mcp",
175+
"object": "chat.completion",
176+
"model": model,
177+
"choices": [{
178+
"index": 0,
179+
"message": {
180+
"role": "assistant",
181+
"content": null,
182+
"tool_calls": [{
183+
"id": "call_1",
184+
"type": "function",
185+
"function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}
186+
}]
187+
},
188+
"finish_reason": "tool_calls"
189+
}],
190+
"usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}
191+
}))
192+
.into_response();
193+
}
194+
156195
let custom_target_schema = body
157196
.pointer("/response_format/json_schema/schema/properties/decision/properties/target")
158197
.is_some();
@@ -2138,3 +2177,119 @@ async fn request_and_upstream_errors_use_the_inbound_wire_format() -> TestResult
21382177
);
21392178
Ok(())
21402179
}
2180+
2181+
// Returns every `data:` frame of an SSE body as JSON, skipping `[DONE]`.
2182+
fn sse_events(body: &str) -> Vec<Value> {
2183+
body.lines()
2184+
.filter_map(|line| line.strip_prefix("data: "))
2185+
.filter(|data| *data != "[DONE]")
2186+
.filter_map(|data| serde_json::from_str(data).ok())
2187+
.collect()
2188+
}
2189+
2190+
// The Codex request shape: MCP tools wrapped in a `namespace` container.
2191+
fn codex_mcp_responses_request(stream: bool) -> Value {
2192+
json!({
2193+
"model": ROUTE_MODEL,
2194+
"input": "mcp-tool-call",
2195+
"stream": stream,
2196+
"tools": [{
2197+
"type": "namespace",
2198+
"name": "mcp__open_websearch__",
2199+
"description": "Web search MCP tools",
2200+
"tools": [{
2201+
"type": "function",
2202+
"name": "search",
2203+
"description": "Search the web",
2204+
"parameters": {
2205+
"type": "object",
2206+
"properties": {"q": {"type": "string"}},
2207+
"required": ["q"]
2208+
}
2209+
}]
2210+
}]
2211+
})
2212+
}
2213+
2214+
// The container is flattened for a Chat-only upstream, and the namespace is
2215+
// restored on the returned function call.
2216+
#[tokio::test]
2217+
async fn responses_buffered_restores_codex_mcp_namespace() -> TestResult {
2218+
const MODEL: &str = "model/mcp-buffered";
2219+
let (upstream, app) = test_app(&[(ROUTE_MODEL, &[MODEL])]).await?;
2220+
2221+
let response = send(
2222+
&app,
2223+
"POST",
2224+
"/v1/responses",
2225+
Some(codex_mcp_responses_request(false)),
2226+
)
2227+
.await?;
2228+
2229+
assert_eq!(response.status, StatusCode::OK);
2230+
let body = response.json()?;
2231+
assert_eq!(body["output"][0]["type"], "function_call");
2232+
assert_eq!(body["output"][0]["name"], "search");
2233+
assert_eq!(body["output"][0]["namespace"], "mcp__open_websearch__");
2234+
2235+
// The upstream must never see the `namespace` container itself.
2236+
let calls = upstream.calls.lock().await;
2237+
let tools = calls[0]["tools"]
2238+
.as_array()
2239+
.ok_or("upstream received no tools")?;
2240+
assert_eq!(tools.len(), 1);
2241+
assert_eq!(tools[0]["type"], "function");
2242+
assert_eq!(tools[0]["function"]["name"], "search");
2243+
assert!(
2244+
calls[0]["tools"][0].get("namespace").is_none(),
2245+
"namespace container leaked upstream"
2246+
);
2247+
Ok(())
2248+
}
2249+
2250+
// The namespace has to survive on every output-item event, not only on the
2251+
// terminal aggregate.
2252+
#[tokio::test]
2253+
async fn responses_stream_restores_codex_mcp_namespace() -> TestResult {
2254+
const MODEL: &str = "model/mcp-stream";
2255+
let (_upstream, app) = test_app(&[(ROUTE_MODEL, &[MODEL])]).await?;
2256+
2257+
let response = send(
2258+
&app,
2259+
"POST",
2260+
"/v1/responses",
2261+
Some(codex_mcp_responses_request(true)),
2262+
)
2263+
.await?;
2264+
2265+
assert_eq!(response.status, StatusCode::OK);
2266+
let events = sse_events(response.text()?);
2267+
2268+
let namespace_of = |event_type: &str| -> Option<Value> {
2269+
events
2270+
.iter()
2271+
.find(|event| event["type"] == event_type)
2272+
.map(|event| event["item"]["namespace"].clone())
2273+
};
2274+
assert_eq!(
2275+
namespace_of("response.output_item.added"),
2276+
Some(json!("mcp__open_websearch__")),
2277+
"namespace missing from response.output_item.added"
2278+
);
2279+
assert_eq!(
2280+
namespace_of("response.output_item.done"),
2281+
Some(json!("mcp__open_websearch__")),
2282+
"namespace missing from response.output_item.done"
2283+
);
2284+
2285+
let completed = events
2286+
.iter()
2287+
.find(|event| event["type"] == "response.completed")
2288+
.ok_or("stream produced no response.completed event")?;
2289+
assert_eq!(
2290+
completed["response"]["output"][0]["namespace"], "mcp__open_websearch__",
2291+
"namespace missing from the response.completed aggregate"
2292+
);
2293+
assert_eq!(completed["response"]["output"][0]["name"], "search");
2294+
Ok(())
2295+
}

0 commit comments

Comments
 (0)