From 90e9d03dec4dafc4e9bb354c7c48c1b4cc02c4ef Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 21:14:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20=E5=BC=BA=E5=88=B6=E9=80=89?= =?UTF-8?q?=E6=8B=A9=20tool=5Fsearch=20=E7=9A=84=20tool=5Fchoice=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=BA=E6=8C=87=E5=90=91=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=9A=84=20function=20=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_search 工具未被丢弃而是降级为同名 function 代理,此前 tool_choice 过滤 把它连同其他服务端工具类型一起静默丢弃,强制工具搜索被退化为自动选择, 违反调用方明确指定的工具选择语义。改为映射到指向代理的 function 选择; 未声明 type=tool_search 时无可指向的代理,维持丢弃。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 26 ++++++++++++------- ...ions_responses_bridge_custom_tools_test.go | 24 +++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 5751163cd7..23178bf3a0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -724,17 +724,25 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[strin // "auto"/"none"/"required" 等字符串形式原样转发。 return raw } - // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 - if t := rawString(choice["type"]); t != "function" && t != "custom" { + var name string + switch rawString(choice["type"]) { + case "tool_search": + // tool_search 未被丢弃而是降级为同名 function 代理(见 + // responsesToolsToChatTools),强制选择它同样降级为 function 选择, + // 静默丢弃会把强制搜索退化为自动选择。 + name = toolSearchProxyName + case "function", "custom": + // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 + name = rawString(choice["name"]) + if name == "" { + name = rawNestedString(choice["function"], "name") + } + if name == "" { + return raw + } + default: return nil } - name := rawString(choice["name"]) - if name == "" { - name = rawNestedString(choice["function"], "name") - } - if name == "" { - return raw - } if !declared[name] { return nil } 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 5f16839eda..9271b50d1d 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 @@ -621,6 +621,30 @@ func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *test assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice)) } +// tool_search 工具没有被丢弃而是降级为同名 function 代理,强制选择它的 tool_choice +// 必须同步降级为指向代理的 function 选择,不能静默丢弃(丢弃会把强制搜索退化为 +// 自动选择,模型可以不执行搜索)。 +func TestResponsesToChatCompletionsRequest_ToolSearchToolChoiceMapsToProxy(t *testing.T) { + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "tool_search"}}, + ToolChoice: json.RawMessage(`{"type":"tool_search"}`), + }) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"function","function":{"name":"tool_search"}}`, string(out.ToolChoice)) + + // 未声明 type=tool_search 时强制选择它没有可指向的代理,丢弃选择项。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"tool_search"}`), + }) + require.NoError(t, err) + assert.Empty(t, out.ToolChoice) +} + // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。