From 79423383287e945a1d953a6f280bf35ea6b7f422 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 17:38:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20=E5=9B=9E=E7=A8=8B=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=20namespace=20=E5=AD=90=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Codex=20MCP=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20unsupported=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 0.14x 将 MCP 工具声明为 namespace 工具,chat 桥去程摊平为 "__" function 工具后,回程仅原样回传平铺名的 function_call 项;codex 按 namespace+name 路由查不到该名字,所有 MCP 工具调用被判为 unsupported call。 - NamespaceToolNames 构建摊平名 →(namespace, 子工具名)反查表 (摊平名超长带截断哈希,无法按字符串切分还原) - 非流式/流式回程命中映射时还原为裸子工具名 + namespace 字段, ResponsesOutput 新增 Namespace 字段并同步 wire 层白名单 - 回退桥入口将映射与 CustomTools/ToolSearchDeclared 一并穿入 已在测试机经 codex exec + MCP server 端到端验证。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 103 ++++++++-- ...ions_responses_bridge_custom_tools_test.go | 190 +++++++++++++++++- .../chatcompletions_responses_test.go | 4 +- .../apicompat/responses_stream_event_wire.go | 5 + backend/internal/pkg/apicompat/types.go | 2 + .../openai_gateway_messages_chat_fallback.go | 2 +- .../openai_gateway_responses_chat_fallback.go | 13 +- 7 files changed, 296 insertions(+), 23 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 52c93be761..6262ed3dd2 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -64,6 +64,42 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { return out } +// NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。 +type NamespacedToolName struct { + Namespace string + Name string +} + +// NamespaceToolNames 收集 Responses 请求中 namespace 子工具的摊平名 →(namespace, +// 子工具名)映射。chat 桥回程时需据此把模型对摊平工具的调用还原为带 namespace 字段 +// 的 function_call 项:codex 按 namespace+name 路由,平铺名会被判为 unsupported +// call;摊平名超长时带截断哈希(见 flattenNamespaceToolName),无法按字符串切分还原。 +func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { + var out map[string]NamespacedToolName + for _, tool := range tools { + if tool.Type != "namespace" || tool.Name == "" { + continue + } + children := tool.Tools + if len(children) == 0 { + children = tool.Children + } + for _, child := range children { + if child.Type != "function" || child.Name == "" { + continue + } + if out == nil { + out = make(map[string]NamespacedToolName) + } + out[flattenNamespaceToolName(tool.Name, child.Name)] = NamespacedToolName{ + Namespace: tool.Name, + Name: child.Name, + } + } + } + return out +} + // HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥 // 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型 // 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配 @@ -679,8 +715,9 @@ func extractCustomToolCallInput(arguments string) string { // response into a Responses API response. customTools 是客户端请求中 custom 工具 // 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项; // toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 -// 的调用会还原为 tool_search_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool) *ResponsesResponse { +// 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名 +// 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。 +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -705,7 +742,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -720,7 +757,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool) []ResponsesOutput { +func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput if message.ReasoningContent != "" { outputs = append(outputs, ResponsesOutput{ @@ -776,6 +813,18 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo }) continue } + if ns, ok := namespaceTools[toolCall.Function.Name]; ok { + outputs = append(outputs, ResponsesOutput{ + Type: "function_call", + ID: generateItemID(), + CallID: toolCall.ID, + Name: ns.Name, + Namespace: ns.Namespace, + Arguments: arguments, + Status: "completed", + }) + continue + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), @@ -905,6 +954,11 @@ type ChatCompletionsToResponsesStreamState struct { // 该项类型(且 execution=client)执行 tool search。 ToolSearchDeclared bool + // NamespaceTools 是 namespace 子工具的摊平名 → 原始归属映射(见 + // NamespaceToolNames)。命中的调用还原为带 namespace 字段的 function_call 项, + // codex 按 namespace+name 路由。 + NamespaceTools map[string]NamespacedToolName + // toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的 // 项类型一致。 toolIsCustom map[int]bool @@ -912,6 +966,9 @@ type ChatCompletionsToResponsesStreamState struct { // toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。 toolIsToolSearch map[int]bool + // toolNamespace 记录工具调用宣告时命中的 namespace 归属(见 NamespaceTools)。 + toolNamespace map[int]NamespacedToolName + // toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字 // 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。 toolAnnounced map[int]bool @@ -931,6 +988,7 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe ToolOutputIndex: make(map[int]int), toolIsCustom: make(map[int]bool), toolIsToolSearch: make(map[int]bool), + toolNamespace: make(map[int]NamespacedToolName), toolAnnounced: make(map[int]bool), } } @@ -1263,7 +1321,7 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared) { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { return nil } state.toolAnnounced[idx] = true @@ -1278,14 +1336,22 @@ func announceChatToolItem( if isToolSearch { itemType = "tool_search_call" } + // namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要 + // 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。 + itemName, itemNamespace := stored.Function.Name, "" + if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch { + state.toolNamespace[idx] = ns + itemName, itemNamespace = ns.Name, ns.Namespace + } events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ OutputIndex: state.ToolOutputIndex[idx], Item: &ResponsesOutput{ - Type: itemType, - ID: state.ToolItemIDs[idx], - CallID: stored.ID, - Name: stored.Function.Name, - Status: "in_progress", + Type: itemType, + ID: state.ToolItemIDs[idx], + CallID: stored.ID, + Name: itemName, + Namespace: itemNamespace, + Status: "in_progress", }, })} // 迟到宣告时补发已累积的参数增量(custom/tool_search 的输入收尾统一下发,不补发)。 @@ -1374,12 +1440,17 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response })) continue } + // namespace 子工具调用在宣告时已记录归属,收尾项同样带还原名与 namespace。 + name, namespace := toolCall.Function.Name, "" + if ns, ok := state.toolNamespace[i]; ok { + name, namespace = ns.Name, ns.Namespace + } events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ OutputIndex: outputIndex, ItemID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, Arguments: arguments, }), chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ @@ -1388,7 +1459,8 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response Type: "function_call", ID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, + Namespace: namespace, Arguments: arguments, Status: "completed", }, @@ -1452,11 +1524,16 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp }) continue } + name, namespace := toolCall.Function.Name, "" + if ns, ok := state.toolNamespace[i]; ok { + name, namespace = ns.Name, ns.Namespace + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, + Namespace: namespace, Arguments: arguments, Status: "completed", }) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index a7428860be..37702ed699 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -103,7 +103,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil) require.Len(t, out.Output, 2) assert.Equal(t, "custom_tool_call", out.Output[0].Type) @@ -226,7 +226,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil) require.Len(t, out.Output, 1) item := out.Output[0] @@ -258,7 +258,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa } // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil) require.Len(t, out.Output, 1) assert.Equal(t, "function_call", out.Output[0].Type) } @@ -506,6 +506,190 @@ func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) { assert.Contains(t, sse, `"type":"custom_tool_call"`) } +func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) { + tools := []ResponsesTool{ + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{ + {Type: "function", Name: "send"}, + {Type: "custom", Name: "skip_me"}, + }}, + {Type: "namespace", Name: "crm", Children: []ResponsesTool{ + {Type: "function", Name: "query"}, + }}, + {Type: "function", Name: "wait"}, + } + + m := NamespaceToolNames(tools) + require.Len(t, m, 2) + assert.Equal(t, NamespacedToolName{Namespace: "gmail", Name: "send"}, m["gmail__send"]) + assert.Equal(t, NamespacedToolName{Namespace: "crm", Name: "query"}, m["crm__query"]) + + // 摊平名超长时截断加哈希,无法按字符串切分还原,必须经映射反查。 + longNS := "very_long_namespace_prefix_for_testing_purposes" + longChild := "and_a_rather_long_tool_name_too" + m2 := NamespaceToolNames([]ResponsesTool{{ + Type: "namespace", Name: longNS, + Tools: []ResponsesTool{{Type: "function", Name: longChild}}, + }}) + assert.Equal(t, NamespacedToolName{Namespace: longNS, Name: longChild}, + m2[flattenNamespaceToolName(longNS, longChild)]) + + assert.Nil(t, NamespaceToolNames(nil)) +} + +// codex 按 namespace+name 路由 namespace 子工具的调用:回程必须把摊平名还原为 +// 裸子工具名并带独立 namespace 字段,平铺名的 function_call 会被 codex 判为 +// unsupported call 拒绝执行。 +func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testing.T) { + resp := &ChatCompletionsResponse{ + ID: "cc-1", + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_n", Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}}, + {ID: "call_9", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}}, + }, + }, + }}, + } + nsTools := map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools) + require.Len(t, out.Output, 2) + + item := out.Output[0] + assert.Equal(t, "function_call", item.Type) + assert.Equal(t, "echo", item.Name) + assert.Equal(t, "mcp__svc", item.Namespace) + assert.Equal(t, "call_n", item.CallID) + assert.Equal(t, `{"text":"hi"}`, item.Arguments) + + // 非流式响应体走 ResponsesOutput.MarshalJSON,namespace 必须落到线上 JSON。 + b, err := json.Marshal(item) + require.NoError(t, err) + assert.Contains(t, string(b), `"namespace":"mcp__svc"`) + assert.Contains(t, string(b), `"name":"echo"`) + + // 未命中映射的普通 function 调用不受影响,且不携带 namespace 字段。 + assert.Equal(t, "wait", out.Output[1].Name) + assert.Empty(t, out.Output[1].Namespace) + b2, err := json.Marshal(out.Output[1]) + require.NoError(t, err) + assert.NotContains(t, string(b2), `"namespace"`) +} + +func TestChatCompletionsChunkToResponsesEvents_NamespacedToolCallStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.NamespaceTools = map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{ + ID: "cc-1", + Choices: []ChatChunkChoice{{ + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + ID: "call_n", + Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}, + }}, + }, + }}, + } + + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + var added, itemDone *ResponsesStreamEvent + for i := range events { + evt := &events[i] + switch evt.Type { + case "response.output_item.added": + if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" { + added = evt + } + case "response.output_item.done": + if evt.Item != nil && evt.Item.Type == "function_call" { + itemDone = evt + } + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + t.Fatalf("namespace 子工具调用不应产出 custom 事件: %s", evt.Type) + } + } + + require.NotNil(t, added, "缺少 namespace 调用的 output_item.added") + assert.Equal(t, "function_call", added.Item.Type) + assert.Equal(t, "echo", added.Item.Name) + assert.Equal(t, "mcp__svc", added.Item.Namespace) + + require.NotNil(t, itemDone, "缺少 namespace 调用的 output_item.done") + assert.Equal(t, "call_n", itemDone.Item.CallID) + assert.Equal(t, "echo", itemDone.Item.Name) + assert.Equal(t, "mcp__svc", itemDone.Item.Namespace) + assert.Equal(t, `{"text":"hi"}`, itemDone.Item.Arguments) + + // SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言 namespace 落线。 + sse, err := ResponsesEventToSSE(*itemDone) + require.NoError(t, err) + assert.Contains(t, sse, `"namespace":"mcp__svc"`) + assert.Contains(t, sse, `"name":"echo"`) + assert.Contains(t, sse, `"call_id":"call_n"`) + + // response.completed 的 output 数组同样携带还原后的 namespace 调用项。 + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.NotNil(t, final.Response) + found := false + for _, item := range final.Response.Output { + if item.Type == "function_call" { + found = true + assert.Equal(t, "echo", item.Name) + assert.Equal(t, "mcp__svc", item.Namespace) + } + } + assert.True(t, found, "response.completed 缺少还原后的 namespace 调用项") +} + +func TestChatCompletionsChunkToResponsesEvents_NamespacedToolNameArrivesLate(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.NamespaceTools = map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + idx := 0 + chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_n", Function: ChatFunctionCall{Arguments: `{"te`}}}, + }}}} + chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `xt":"hi"}`}}}, + }}}} + + var events []ResponsesStreamEvent + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...) + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + addedCount := 0 + deltas := "" + for _, evt := range events { + switch evt.Type { + case "response.output_item.added": + if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" { + addedCount++ + assert.Equal(t, "echo", evt.Item.Name, "迟到的名字命中 namespace 映射时按还原名宣告") + assert.Equal(t, "mcp__svc", evt.Item.Namespace) + } + case "response.function_call_arguments.delta": + deltas += evt.Delta + } + } + assert.Equal(t, 1, addedCount, "工具调用只宣告一次") + assert.Equal(t, `{"text":"hi"}`, deltas, "宣告前累积的参数需在宣告时补发") +} + func TestChatCompletionsChunkToResponsesEvents_FunctionToolStreamUnaffected(t *testing.T) { state := NewChatCompletionsToResponsesStreamState("glm-5.2") state.CustomTools = map[string]bool{"exec": true} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 66afcf2270..775a04eaa0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -418,7 +418,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -452,7 +452,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/responses_stream_event_wire.go b/backend/internal/pkg/apicompat/responses_stream_event_wire.go index 58b6831b10..c2ebcd3eee 100644 --- a/backend/internal/pkg/apicompat/responses_stream_event_wire.go +++ b/backend/internal/pkg/apicompat/responses_stream_event_wire.go @@ -184,6 +184,11 @@ func responsesItemWire(item *ResponsesOutput) map[string]any { m["call_id"] = item.CallID m["name"] = item.Name m["arguments"] = item.Arguments + // namespace 子工具的还原调用:codex 按 namespace+name 路由,缺少该字段 + // 会被判为 unsupported call。 + if item.Namespace != "" { + m["namespace"] = item.Namespace + } case "custom_tool_call": // custom/freeform 工具调用(如 codex 的 exec):input 为自由文本。缺少 // call_id/name 时 codex 无法路由该调用(表现为 unsupported call)。 diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 41969bc024..0eda9c329d 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -321,6 +321,8 @@ type ResponsesOutput struct { CallID string `json:"call_id,omitempty"` Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` + // 来源为 namespace 子工具时的归属命名空间(codex 按 namespace+name 路由该调用)。 + Namespace string `json:"namespace,omitempty"` // type=custom_tool_call(custom/freeform 工具,input 为自由文本) Input string `json:"input,omitempty"` diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 9877e66af7..fed065ba0a 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -140,7 +140,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, nil, false) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, nil, false, nil) anthropicResp := apicompat.ResponsesToAnthropic(responsesResp, originalModel) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index e722f86969..0a29996fa8 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -42,9 +42,11 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 - // tool_search_call 项。 + // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原 + // 为带 namespace 字段的 function_call 项。 customTools := apicompat.CustomToolNames(responsesReq.Tools) toolSearch := apicompat.HasToolSearchTool(responsesReq.Tools) + namespaceTools := apicompat.NamespaceToolNames(responsesReq.Tools) chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) if err != nil { @@ -105,9 +107,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -116,6 +118,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( originalModel string, customTools map[string]bool, toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -127,7 +130,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -153,6 +156,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( originalModel string, customTools map[string]bool, toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -165,6 +169,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools state.ToolSearchDeclared = toolSearch + state.NamespaceTools = namespaceTools clientDisconnected := false writeEvents := func(events []apicompat.ResponsesStreamEvent) {