fix(apicompat): namespace 摊平名撞名时显式拒绝请求

摊平名与顶层 function/custom 工具撞名、或跨 namespace 摊平出同名时,
chat 上游无法按 namespace 区分调用归属:此前重复声明照发上游、回程
固定命中其中一条映射,调用可能被还原到错误工具。这类请求在原生
Responses 上游合法,歧义由摊平转换制造且不可消除,改为在请求转换
阶段直接报错(网关返回 400 invalid_request_error 并点名冲突双方);
同一 (namespace, 子工具) 的重复声明去重后不拒绝。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU
This commit is contained in:
xlplbo
2026-07-10 17:38:48 +08:00
co-authored by Claude Fable 5
parent 7942338328
commit f1082bb78f
2 changed files with 90 additions and 9 deletions
@@ -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 工具,名字加 "<namespace>__" 前缀。
func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool {
// function 工具,名字加 "<namespace>__" 前缀。摊平名与顶层工具或其他 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 工具名的通用长度上限。
@@ -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 拒绝执行。