From e2b68d1f905005f394117643f4e1fed512d1ad3e Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 21:06:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20tool=5Fchoice=20=E4=BB=85?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E5=AE=9E=E9=99=85=E5=AD=98=E5=9C=A8=E4=BA=8E?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E7=BB=93=E6=9E=9C=E4=B8=AD=E7=9A=84=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前只检查转换后是否还剩任意工具,不校验 tool_choice 指向的工具是否幸存: 强制选择被丢弃的服务端工具(如 web_search)或指向不存在名字时,选择项被 原样转发,chat 上游因 tool_choice 指向未声明工具而 400。改为具名选择项仅 在目标存在于转换后工具集时转发,服务端工具类选择项随工具本身丢弃; "auto" 等字符串形式保持原样转发。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 23 +++++++-- ...ions_responses_bridge_custom_tools_test.go | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index c69dba6df4..5751163cd7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -44,8 +44,17 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR } // tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发 // tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。 + // 指向被丢弃工具的选择项同理(见 responsesToolChoiceToChatToolChoice)。 if len(out.Tools) > 0 && len(req.ToolChoice) > 0 { - out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) + declared := make(map[string]bool, len(out.Tools)) + for _, tool := range out.Tools { + if tool.Function != nil { + declared[tool.Function.Name] = true + } + } + if tc := responsesToolChoiceToChatToolChoice(req.ToolChoice, declared); len(tc) > 0 { + out.ToolChoice = tc + } } if req.Text != nil { out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format) @@ -705,14 +714,19 @@ func flattenNamespaceToolName(namespace, name string) string { return prefix.String() + suffix } -func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { +// responsesToolChoiceToChatToolChoice 把 Responses 的 tool_choice 转为 chat 形态。 +// declared 是转换后实际声明的 chat 工具名集合:具名选择项仅在目标工具幸存时转发, +// 服务端工具(web_search 等)的选择项随工具本身丢弃——指向未声明工具的 tool_choice +// 会被 chat 上游 400 拒绝。返回 nil 表示丢弃 tool_choice。 +func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[string]bool) json.RawMessage { var choice map[string]json.RawMessage if err := json.Unmarshal(raw, &choice); err != nil { + // "auto"/"none"/"required" 等字符串形式原样转发。 return raw } // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 if t := rawString(choice["type"]); t != "function" && t != "custom" { - return raw + return nil } name := rawString(choice["name"]) if name == "" { @@ -721,6 +735,9 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { if name == "" { return raw } + if !declared[name] { + return nil + } out, err := json.Marshal(map[string]any{ "type": "function", "function": map[string]string{ 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 e6893429f8..5f16839eda 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 @@ -574,6 +574,53 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test assert.Equal(t, "tool_search", out.Tools[0].Function.Name) } +// tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发, +// chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。 +func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) { + // 强制选择被丢弃的 web_search:工具没了,选择项也必须丢。 + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + {Type: "web_search"}, + }, + ToolChoice: json.RawMessage(`{"type":"web_search"}`), + }) + require.NoError(t, err) + require.Len(t, out.Tools, 1) + assert.Empty(t, out.ToolChoice, "指向被丢弃服务端工具的 tool_choice 必须丢弃") + + // 具名选择指向不存在的工具名。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"function","name":"missing"}`), + }) + require.NoError(t, err) + assert.Empty(t, out.ToolChoice, "指向不存在工具名的 tool_choice 必须丢弃") + + // 字符串形式与指向幸存工具的选择保持原有转发行为。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`"auto"`), + }) + require.NoError(t, err) + assert.JSONEq(t, `"auto"`, string(out.ToolChoice)) + + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"function","name":"wait"}`), + }) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice)) +} + // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。