feat(apicompat): 添加 tool_search 支持,增强工具调用处理逻辑

This commit is contained in:
xlplbo
2026-07-07 19:19:35 +08:00
parent 75fb3c41c2
commit 27e29f0562
7 changed files with 299 additions and 24 deletions
@@ -61,6 +61,19 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool {
return out
}
// HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥
// 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型
// 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配
// 触发 fatal 中止整个 turn。
func HasToolSearchTool(tools []ResponsesTool) bool {
for _, tool := range tools {
if tool.Type == "tool_search" {
return true
}
}
return false
}
// responsesInputToChatMessages converts a Responses request's instructions +
// input[] into Chat Completions messages. It is a three-stage pipeline:
//
@@ -661,8 +674,10 @@ func extractCustomToolCallInput(arguments string) string {
// ChatCompletionsResponseToResponses converts a non-streaming Chat Completions
// response into a Responses API response. customTools 是客户端请求中 custom 工具
// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项。
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool) *ResponsesResponse {
// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项;
// toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具
// 的调用会还原为 tool_search_call 项。
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool) *ResponsesResponse {
id := ""
if resp != nil {
id = resp.ID
@@ -687,7 +702,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
out.Output = chatMessageToResponsesOutput(choice.Message, customTools)
out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch)
if choice.FinishReason == "length" {
out.Status = "incomplete"
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
@@ -702,7 +717,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
return out
}
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool) []ResponsesOutput {
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool) []ResponsesOutput {
var outputs []ResponsesOutput
if message.ReasoningContent != "" {
outputs = append(outputs, ResponsesOutput{
@@ -748,6 +763,16 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo
})
continue
}
if toolSearch && toolCall.Function.Name == toolSearchProxyName {
outputs = append(outputs, ResponsesOutput{
Type: "tool_search_call",
ID: generateItemID(),
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
})
continue
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
@@ -761,6 +786,21 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo
return outputs
}
// toolSearchCallArgumentsJSON 把降级 function 调用累积的 arguments 字符串还原为
// tool_search_call 线上要求的 JSON 对象;模型未按 schema 输出(非法 JSON)时按
// 字符串值兜底,交由 codex 解析报错后让模型重试。
func toolSearchCallArgumentsJSON(arguments string) json.RawMessage {
trimmed := strings.TrimSpace(arguments)
if trimmed == "" {
return json.RawMessage(`{}`)
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
fallback, _ := json.Marshal(arguments)
return fallback
}
func emptyResponsesMessageOutput() ResponsesOutput {
return ResponsesOutput{
Type: "message",
@@ -857,10 +897,18 @@ type ChatCompletionsToResponsesStreamState struct {
// 路由回它注册的 custom 工具。
CustomTools map[string]bool
// ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见
// HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按
// 该项类型(且 execution=client)执行 tool search。
ToolSearchDeclared bool
// toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的
// 项类型一致。
toolIsCustom map[int]bool
// toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。
toolIsToolSearch map[int]bool
// toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字
// 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。
toolAnnounced map[int]bool
@@ -872,14 +920,15 @@ type ChatCompletionsToResponsesStreamState struct {
// NewChatCompletionsToResponsesStreamState returns an initialized stream state.
func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToResponsesStreamState {
return &ChatCompletionsToResponsesStreamState{
ResponseID: generateResponsesID(),
Model: model,
Created: time.Now().Unix(),
ToolCalls: make(map[int]*ChatToolCall),
ToolItemIDs: make(map[int]string),
ToolOutputIndex: make(map[int]int),
toolIsCustom: make(map[int]bool),
toolAnnounced: make(map[int]bool),
ResponseID: generateResponsesID(),
Model: model,
Created: time.Now().Unix(),
ToolCalls: make(map[int]*ChatToolCall),
ToolItemIDs: make(map[int]string),
ToolOutputIndex: make(map[int]int),
toolIsCustom: make(map[int]bool),
toolIsToolSearch: make(map[int]bool),
toolAnnounced: make(map[int]bool),
}
}
@@ -978,8 +1027,9 @@ func ChatCompletionsChunkToResponsesEvents(
stored.Function.Arguments += toolCall.Function.Arguments
// 未宣告(名字未到)时仅累积,宣告时统一补发;custom 调用的
// arguments 是包裹 input 的 JSON 片段,无法增量还原为自由文本
// 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems)。
if state.toolAnnounced[idx] && !state.toolIsCustom[idx] {
// 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems);
// tool_search 调用同样收尾时随 output_item.done 全量下发。
if state.toolAnnounced[idx] && !state.toolIsCustom[idx] && !state.toolIsToolSearch[idx] {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
@@ -1210,16 +1260,21 @@ func announceChatToolItem(
if state.toolAnnounced[idx] {
return nil
}
if !force && stored.Function.Name == "" && len(state.CustomTools) > 0 {
if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared) {
return nil
}
state.toolAnnounced[idx] = true
isCustom := state.CustomTools[stored.Function.Name]
isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName
state.toolIsCustom[idx] = isCustom
state.toolIsToolSearch[idx] = isToolSearch
itemType := "function_call"
if isCustom {
itemType = "custom_tool_call"
}
if isToolSearch {
itemType = "tool_search_call"
}
events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
Item: &ResponsesOutput{
@@ -1230,8 +1285,8 @@ func announceChatToolItem(
Status: "in_progress",
},
})}
// 迟到宣告时补发已累积的参数增量(custom 工具的输入收尾统一下发,不补发)。
if !isCustom && stored.Function.Arguments != "" {
// 迟到宣告时补发已累积的参数增量(custom/tool_search 的输入收尾统一下发,不补发)。
if !isCustom && !isToolSearch && stored.Function.Arguments != "" {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
@@ -1301,6 +1356,21 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
)
continue
}
if state.toolIsToolSearch[i] {
// tool_search 调用按 tool_search_call 项收尾:codex 从 output_item.done
// 物化该调用(无参数增量事件),arguments 全量随项下发。
events = append(events, chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
Item: &ResponsesOutput{
Type: "tool_search_call",
ID: itemID,
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
},
}))
continue
}
events = append(events,
chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
@@ -1369,6 +1439,16 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp
})
continue
}
if state.toolIsToolSearch[i] {
outputs = append(outputs, ResponsesOutput{
Type: "tool_search_call",
ID: generateItemID(),
CallID: toolCall.ID,
Arguments: arguments,
Status: "completed",
})
continue
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
@@ -103,7 +103,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.
}},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true})
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false)
require.Len(t, out.Output, 2)
assert.Equal(t, "custom_tool_call", out.Output[0].Type)
@@ -210,6 +210,131 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t
assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`)
}
// codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行
// tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配
// 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。
func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing.T) {
resp := &ChatCompletionsResponse{
ID: "cc-1",
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail","limit":2}`}},
},
},
}},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true)
require.Len(t, out.Output, 1)
item := out.Output[0]
assert.Equal(t, "tool_search_call", item.Type)
assert.Equal(t, "call_s", item.CallID)
// 线上形态:execution 必须为 "client"(codex 的必填字段,非 client 被忽略),
// arguments 必须是 JSON 对象而非字符串(codex 按对象解析 query/limit)。
b, err := json.Marshal(item)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(b, &m))
assert.Equal(t, "client", m["execution"])
args, ok := m["arguments"].(map[string]any)
require.True(t, ok, "arguments 必须序列化为 JSON 对象")
assert.Equal(t, "gmail", args["query"])
}
func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCall(t *testing.T) {
resp := &ChatCompletionsResponse{
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`}},
},
},
}},
}
// 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false)
require.Len(t, out.Output, 1)
assert.Equal(t, "function_call", out.Output[0].Type)
}
func TestChatCompletionsChunkToResponsesEvents_ToolSearchCallStream(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.ToolSearchDeclared = true
idx := 0
chunk := &ChatCompletionsChunk{
ID: "cc-1",
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_s",
Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
var added, itemDone *ResponsesStreamEvent
for i := range events {
evt := &events[i]
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
added = evt
}
case "response.output_item.done":
if evt.Item != nil && evt.Item.Type == "tool_search_call" {
itemDone = evt
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done",
"response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
t.Fatalf("tool_search 调用不应产出 %s", evt.Type)
}
}
require.NotNil(t, added, "缺少 tool_search_call 的 output_item.added")
assert.Equal(t, "tool_search_call", added.Item.Type)
require.NotNil(t, itemDone, "缺少 tool_search_call 的 output_item.done")
assert.Equal(t, "call_s", itemDone.Item.CallID)
// SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言。
sse, err := ResponsesEventToSSE(*itemDone)
require.NoError(t, err)
assert.Contains(t, sse, `"execution":"client"`)
assert.Contains(t, sse, `"arguments":{"query":"gmail"}`)
assert.Contains(t, sse, `"call_id":"call_s"`)
// response.completed 的 output 数组同样携带 tool_search_call 项。
final := events[len(events)-1]
require.Equal(t, "response.completed", final.Type)
require.NotNil(t, final.Response)
found := false
for _, item := range final.Response.Output {
if item.Type == "tool_search_call" {
found = true
assert.Equal(t, "call_s", item.CallID)
}
}
assert.True(t, found, "response.completed 缺少 tool_search_call 输出项")
}
func TestHasToolSearchTool(t *testing.T) {
assert.True(t, HasToolSearchTool([]ResponsesTool{{Type: "tool_search"}}))
assert.False(t, HasToolSearchTool([]ResponsesTool{{Type: "function", Name: "tool_search"}}))
assert.False(t, HasToolSearchTool(nil))
}
func TestResponsesToChatCompletionsRequest_NamespaceToolFlattensChildren(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
@@ -362,7 +362,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess
}},
}
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil)
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false)
require.Len(t, out.Output, 2)
require.Equal(t, "reasoning", out.Output[0].Type)
@@ -396,7 +396,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall
}},
}
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil)
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false)
require.Len(t, out.Output, 2)
require.Equal(t, "reasoning", out.Output[0].Type)
@@ -86,6 +86,23 @@ func (e ResponsesStreamEvent) MarshalJSON() ([]byte, error) {
}
return json.Marshal(m)
case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
m := e.wireBase()
e.putItemID(m)
m["output_index"] = e.OutputIndex
if e.CallID != "" {
m["call_id"] = e.CallID
}
if e.Name != "" {
m["name"] = e.Name
}
if e.Type == "response.custom_tool_call_input.done" {
m["input"] = e.Input
} else {
m["delta"] = e.Delta
}
return json.Marshal(m)
default:
// response.created / completed / done / failed / incomplete and any
// event type not shaped above keep the default struct marshalling.
@@ -173,6 +190,12 @@ func responsesItemWire(item *ResponsesOutput) map[string]any {
m["call_id"] = item.CallID
m["name"] = item.Name
m["input"] = item.Input
case "tool_search_call":
// tool_search 调用还原项:execution 必须为 "client"(否则 codex 忽略该
// 调用),arguments 在线上是 JSON 对象而非字符串。
m["call_id"] = item.CallID
m["execution"] = "client"
m["arguments"] = toolSearchCallArgumentsJSON(item.Arguments)
}
return m
}
@@ -102,6 +102,26 @@ func TestWire_ArgumentsDonePresentEvenEmpty(t *testing.T) {
require.Equal(t, "", m["arguments"])
}
// TestWire_CustomToolCallInputIndexPresentAtZero guards the omitempty trap for
// custom_tool_call_input.delta/done: output_index must serialize even when 0
// (custom tool call as the first output item).
func TestWire_CustomToolCallInputIndexPresentAtZero(t *testing.T) {
d := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.delta", OutputIndex: 0, ItemID: "ct_1", Delta: "dir",
})
require.Contains(t, d, "output_index")
require.EqualValues(t, 0, d["output_index"])
require.Equal(t, "dir", d["delta"])
done := marshalEvent(t, ResponsesStreamEvent{
Type: "response.custom_tool_call_input.done", OutputIndex: 0, ItemID: "ct_1", CallID: "call_1", Name: "exec", Input: "dir",
})
require.Contains(t, done, "output_index")
require.EqualValues(t, 0, done["output_index"])
require.Equal(t, "dir", done["input"])
require.NotContains(t, done, "delta")
}
// TestWire_UnknownEventFallsBackToDefault ensures non-streamed event types keep
// default marshalling (the response object is preserved).
func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
+22
View File
@@ -328,6 +328,28 @@ type ResponsesOutput struct {
Action *WebSearchAction `json:"action,omitempty"`
}
// MarshalJSON 处理 tool_search_call 项的线上形态(复用 CallID/Arguments 字段):
// execution 固定为 "client"(codex 的必填字段,非 client 的调用会被静默忽略),
// arguments 是 JSON 对象而非 function_call 语义下的字符串。其余类型走默认结构体
// 序列化,输出逐字节不变。
func (o ResponsesOutput) MarshalJSON() ([]byte, error) {
type responsesOutputAlias ResponsesOutput
if o.Type != "tool_search_call" {
return json.Marshal(responsesOutputAlias(o))
}
m := map[string]any{
"type": o.Type,
"id": o.ID,
"call_id": o.CallID,
"execution": "client",
"arguments": toolSearchCallArgumentsJSON(o.Arguments),
}
if o.Status != "" {
m["status"] = o.Status
}
return json.Marshal(m)
}
// WebSearchAction describes the search action in a web_search_call output item.
type WebSearchAction struct {
Type string `json:"type,omitempty"` // "search"
@@ -54,8 +54,10 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel)
serviceTier := extractOpenAIServiceTierFromBody(body)
// custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为
// custom_tool_call 项,先记下名字集合。
// custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为
// tool_search_call 项。
customTools := apicompat.CustomToolNames(responsesReq.Tools)
toolSearch := apicompat.HasToolSearchTool(responsesReq.Tools)
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq)
if err != nil {
@@ -194,9 +196,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
}
if clientStream {
return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
}
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
}
func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
@@ -204,6 +206,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
resp *http.Response,
originalModel string,
customTools map[string]bool,
toolSearch bool,
billingModel string,
upstreamModel string,
reasoningEffort *string,
@@ -234,7 +237,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
})
return nil, fmt.Errorf("parse chat completions response: %w", err)
}
responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools)
responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools, toolSearch)
usage := OpenAIUsage{}
if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
@@ -264,6 +267,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
resp *http.Response,
originalModel string,
customTools map[string]bool,
toolSearch bool,
billingModel string,
upstreamModel string,
reasoningEffort *string,
@@ -289,6 +293,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel)
state.CustomTools = customTools
state.ToolSearchDeclared = toolSearch
var usage OpenAIUsage
var firstTokenMs *int
clientDisconnected := false