fix(apicompat): 强制选择 tool_search 的 tool_choice 降级为指向代理的 function 选择

tool_search 工具未被丢弃而是降级为同名 function 代理,此前 tool_choice 过滤
把它连同其他服务端工具类型一起静默丢弃,强制工具搜索被退化为自动选择,
违反调用方明确指定的工具选择语义。改为映射到指向代理的 function 选择;
未声明 type=tool_search 时无可指向的代理,维持丢弃。

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 21:14:33 +08:00
co-authored by Claude Fable 5
parent e2b68d1f90
commit 90e9d03dec
2 changed files with 41 additions and 9 deletions
@@ -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
}
@@ -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),而不是
// 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。