@@ -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