mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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 拒绝执行。
|
||||
|
||||
Reference in New Issue
Block a user