mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(apicompat): tool_choice 仅转发实际存在于转换结果中的工具
此前只检查转换后是否还剩任意工具,不校验 tool_choice 指向的工具是否幸存: 强制选择被丢弃的服务端工具(如 web_search)或指向不存在名字时,选择项被 原样转发,chat 上游因 tool_choice 指向未声明工具而 400。改为具名选择项仅 在目标存在于转换后工具集时转发,服务端工具类选择项随工具本身丢弃; "auto" 等字符串形式保持原样转发。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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),而不是
|
||||
// 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。
|
||||
|
||||
Reference in New Issue
Block a user