diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 6262ed3dd2..20391254fc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -35,7 +35,11 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR out.ReasoningEffort = req.Reasoning.Effort } if len(req.Tools) > 0 { - out.Tools = responsesToolsToChatTools(req.Tools) + tools, err := responsesToolsToChatTools(req.Tools) + if err != nil { + return nil, err + } + out.Tools = tools } // tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发 // tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。 @@ -74,6 +78,8 @@ type NamespacedToolName struct { // 子工具名)映射。chat 桥回程时需据此把模型对摊平工具的调用还原为带 namespace 字段 // 的 function_call 项:codex 按 namespace+name 路由,平铺名会被判为 unsupported // call;摊平名超长时带截断哈希(见 flattenNamespaceToolName),无法按字符串切分还原。 +// 摊平名撞名的请求已在转换阶段被显式拒绝(见 namespaceChildrenToChatTools), +// 此处映射不存在歧义。 func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { var out map[string]NamespacedToolName for _, tool := range tools { @@ -554,7 +560,18 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra // extractCustomToolCallInput)。 const customToolInputSchema = `{"type":"object","properties":{"input":{"type":"string","description":"The raw input for this tool, passed through verbatim."}},"required":["input"]}` -func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool { +func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { + // 顶层 function/custom 工具名集合:namespace 子工具摊平后与其撞名时,chat + // 上游无法按 namespace 区分调用归属。这类请求在原生 Responses 上游是合法的 + // (按 namespace+name 路由),歧义由摊平转换制造且无法消除,必须显式拒绝, + // 不能静默降级(重复声明发给上游、回程还原到错误工具)。 + topLevel := make(map[string]bool) + for _, tool := range tools { + if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" { + topLevel[tool.Name] = true + } + } + flatOwner := make(map[string]NamespacedToolName) out := make([]ChatTool, 0, len(tools)) for _, tool := range tools { switch tool.Type { @@ -582,12 +599,16 @@ func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool { case "tool_search": out = append(out, toolSearchProxyChatTool()) case "namespace": - out = append(out, namespaceChildrenToChatTools(tool)...) + flattened, err := namespaceChildrenToChatTools(tool, topLevel, flatOwner) + if err != nil { + return nil, err + } + out = append(out, flattened...) } // 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有 // 对应能力,维持丢弃。 } - return out + return out, nil } // toolSearchProxyName 是 tool_search 服务端工具降级后的 function 工具名。模型对 @@ -608,10 +629,12 @@ func toolSearchProxyChatTool() ChatTool { } // namespaceChildrenToChatTools 将 namespace 工具的子 function 工具摊平为顶层 -// function 工具,名字加 "__" 前缀。 -func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool { +// function 工具,名字加 "__" 前缀。摊平名与顶层工具或其他 namespace +// 撞名时返回错误(歧义不可消除,显式拒绝);同一 (namespace, 子工具) 的重复声明 +// 去重后不算冲突。 +func namespaceChildrenToChatTools(tool ResponsesTool, topLevel map[string]bool, flatOwner map[string]NamespacedToolName) ([]ChatTool, error) { if tool.Name == "" { - return nil + return nil, nil } children := tool.Tools if len(children) == 0 { @@ -622,17 +645,29 @@ func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool { if child.Type != "function" || child.Name == "" { continue } + flat := flattenNamespaceToolName(tool.Name, child.Name) + entry := NamespacedToolName{Namespace: tool.Name, Name: child.Name} + if topLevel[flat] { + return nil, fmt.Errorf("namespace tool %q/%q flattens to %q which conflicts with a top-level tool of the same name; this upstream cannot disambiguate them, rename one of the tools", tool.Name, child.Name, flat) + } + if prev, ok := flatOwner[flat]; ok { + if prev == entry { + continue + } + return nil, fmt.Errorf("namespace tools %q/%q and %q/%q both flatten to %q; this upstream cannot disambiguate them, rename one of the tools", prev.Namespace, prev.Name, tool.Name, child.Name, flat) + } + flatOwner[flat] = entry out = append(out, ChatTool{ Type: "function", Function: &ChatFunction{ - Name: flattenNamespaceToolName(tool.Name, child.Name), + Name: flat, Description: child.Description, Parameters: child.Parameters, Strict: child.Strict, }, }) } - return out + return out, nil } // chatToolNameMaxLen 是 Chat Completions function 工具名的通用长度上限。 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 37702ed699..778ce08a62 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 @@ -536,6 +536,52 @@ func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) { assert.Nil(t, NamespaceToolNames(nil)) } +// 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), +// 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 +// 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。 +func TestResponsesToChatCompletionsRequest_RejectsAmbiguousFlattenedNames(t *testing.T) { + // 摊平名与顶层 function 工具撞名。 + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "function", Name: "gmail__send"}, + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{{Type: "function", Name: "send"}}}, + }, + }) + require.Error(t, err, "与顶层工具撞名的摊平必须拒绝") + assert.Contains(t, err.Error(), "gmail__send") + + // 不同 namespace 组合产生相同摊平名。 + _, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "namespace", Name: "a", Tools: []ResponsesTool{{Type: "function", Name: "b__c"}}}, + {Type: "namespace", Name: "a__b", Tools: []ResponsesTool{{Type: "function", Name: "c"}}}, + }, + }) + require.Error(t, err, "跨 namespace 撞名的摊平必须拒绝") + assert.Contains(t, err.Error(), "a__b__c") +} + +// 完全相同的 (namespace, 子工具) 重复声明不构成歧义:去重后正常转换,不拒绝。 +func TestResponsesToChatCompletionsRequest_DedupesIdenticalNamespaceChildren(t *testing.T) { + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{ + {Type: "function", Name: "send"}, + {Type: "function", Name: "send"}, + }}, + }, + }) + require.NoError(t, err) + require.Len(t, out.Tools, 1, "重复声明的同一子工具只声明一次") + assert.Equal(t, "gmail__send", out.Tools[0].Function.Name) +} + // codex 按 namespace+name 路由 namespace 子工具的调用:回程必须把摊平名还原为 // 裸子工具名并带独立 namespace 字段,平铺名的 function_call 会被 codex 判为 // unsupported call 拒绝执行。