diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 62b3a885cd..23178bf3a0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -1,6 +1,8 @@ package apicompat import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "strings" @@ -34,10 +36,25 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR out.ReasoningEffort = req.Reasoning.Effort } if len(req.Tools) > 0 { - out.Tools = responsesToolsToChatTools(req.Tools) + tools, err := responsesToolsToChatTools(req.Tools) + if err != nil { + return nil, err + } + out.Tools = tools } - if len(req.ToolChoice) > 0 { - out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) + // 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 { + 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) @@ -46,6 +63,72 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR return out, nil } +// CustomToolNames 收集 Responses 请求中 custom/freeform 工具的名字。chat 桥回程时 +// 需要据此把模型对这些工具的调用还原为 custom_tool_call 项(codex 只按该类型路由)。 +func CustomToolNames(tools []ResponsesTool) map[string]bool { + var out map[string]bool + for _, tool := range tools { + if tool.Type == "custom" && tool.Name != "" { + if out == nil { + out = make(map[string]bool) + } + out[tool.Name] = true + } + } + return out +} + +// NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。 +type NamespacedToolName struct { + Namespace string + Name string +} + +// NamespaceToolNames 收集 Responses 请求中 namespace 子工具的摊平名 →(namespace, +// 子工具名)映射。chat 桥回程时需据此把模型对摊平工具的调用还原为带 namespace 字段 +// 的 function_call 项:codex 按 namespace+name 路由,平铺名会被判为 unsupported +// call;摊平名超长时带截断哈希(见 flattenNamespaceToolName),无法按字符串切分还原。 +// 摊平名撞名的请求已在转换阶段被显式拒绝(见 namespaceChildrenToChatTools), +// 此处映射不存在歧义。 +func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { + var out map[string]NamespacedToolName + for _, tool := range tools { + if tool.Type != "namespace" || tool.Name == "" { + continue + } + children := tool.Tools + if len(children) == 0 { + children = tool.Children + } + for _, child := range children { + if child.Type != "function" || child.Name == "" { + continue + } + if out == nil { + out = make(map[string]NamespacedToolName) + } + out[flattenNamespaceToolName(tool.Name, child.Name)] = NamespacedToolName{ + Namespace: tool.Name, + Name: child.Name, + } + } + } + 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: // @@ -133,33 +216,68 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa if strings.TrimSpace(arguments) == "" { arguments = "{}" } + name := rawString(item["name"]) + // namespace 子工具的历史调用带 namespace 字段,需与请求方向的摊平 + // 命名(namespaceChildrenToChatTools)保持一致。 + if ns := rawString(item["namespace"]); ns != "" { + name = flattenNamespaceToolName(ns, name) + } + toolCall := ChatToolCall{ + ID: rawString(item["call_id"]), + Type: "function", + Function: ChatFunctionCall{ + Name: name, + Arguments: arguments, + }, + } + messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + pendingReasoning = "" + continue + case "tool_search_call": + // tool_search 调用的 arguments 是 JSON 对象(如 {"query": ...}), + // 原文即为降级 function 调用的 arguments 字符串。 + arguments := strings.TrimSpace(string(bytesTrimSpace(item["arguments"]))) + if s := rawString(item["arguments"]); s != "" { + arguments = s + } + if arguments == "" || arguments == "null" { + arguments = "{}" + } + toolCall := ChatToolCall{ + ID: rawString(item["call_id"]), + Type: "function", + Function: ChatFunctionCall{ + Name: toolSearchProxyName, + Arguments: arguments, + }, + } + messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + pendingReasoning = "" + continue + case "custom_tool_call": + // custom/freeform 工具的历史调用:input 自由文本包进降级 function 工具 + // 的 {"input": ...} 参数,与请求方向的工具降级(customToolInputSchema) + // 保持一致,模型才能把历史与当前工具定义对上。 + arguments, _ := json.Marshal(map[string]string{"input": rawString(item["input"])}) toolCall := ChatToolCall{ ID: rawString(item["call_id"]), Type: "function", Function: ChatFunctionCall{ Name: rawString(item["name"]), - Arguments: arguments, + Arguments: string(arguments), }, } - // Parallel tool calls arrive as consecutive function_call items and - // must share one assistant message; the matching tool replies then - // follow it. Merge into the immediately preceding assistant message. - if n := len(messages); n > 0 && messages[n-1].Role == "assistant" { - messages[n-1].ToolCalls = append(messages[n-1].ToolCalls, toolCall) - if messages[n-1].ReasoningContent == "" { - messages[n-1].ReasoningContent = pendingReasoning - } - } else { - messages = append(messages, ChatMessage{ - Role: "assistant", - ToolCalls: []ChatToolCall{toolCall}, - ReasoningContent: pendingReasoning, - }) - } + messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) pendingReasoning = "" continue - case "function_call_output": - content, _ := json.Marshal(rawString(item["output"])) + case "function_call_output", "custom_tool_call_output", "tool_search_output": + outputRaw := bytesTrimSpace(item["output"]) + outputText := rawString(outputRaw) + if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` { + // 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。 + outputText = string(outputRaw) + } + content, _ := json.Marshal(outputText) messages = append(messages, ChatMessage{ Role: "tool", ToolCallID: rawString(item["call_id"]), @@ -184,9 +302,9 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa // Only genuine message items become chat messages. Codex emits other // Responses item types with no Chat equivalent (web_search_call, - // local_shell_call, custom tool calls, file_search_call, ...). Converting - // them via the generic path would insert a spurious message between an - // assistant tool_calls message and its tool reply, which DeepSeek rejects + // local_shell_call, file_search_call, ...). Converting them via the + // generic path would insert a spurious message between an assistant + // tool_calls message and its tool reply, which DeepSeek rejects // ("insufficient tool messages following tool_calls message"). Skip them. if itemType != "" && itemType != "message" { pendingReasoning = "" @@ -213,6 +331,25 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa return messages, nil } +// appendAssistantToolCall merges a tool call into the chat message list. +// Parallel tool calls arrive as consecutive *_call items and must share one +// assistant message; the matching tool replies then follow it. Merge into the +// immediately preceding assistant message. +func appendAssistantToolCall(messages []ChatMessage, toolCall ChatToolCall, pendingReasoning string) []ChatMessage { + if n := len(messages); n > 0 && messages[n-1].Role == "assistant" { + messages[n-1].ToolCalls = append(messages[n-1].ToolCalls, toolCall) + if messages[n-1].ReasoningContent == "" { + messages[n-1].ReasoningContent = pendingReasoning + } + return messages + } + return append(messages, ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{toolCall}, + ReasoningContent: pendingReasoning, + }) +} + // normalizeChatMessages is the single place that enforces the tool-call // invariant the DeepSeek / OpenAI Chat Completions schema requires: an assistant // message with tool_calls must be immediately followed by one tool message per @@ -427,39 +564,187 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra } } -func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool { +// customToolInputSchema 是 custom/freeform 工具降级为 function 工具时的参数 schema。 +// chat 协议无法表达 custom 工具的自由文本输入(及其 grammar 约束),退化为单一 +// input 字符串参数;回程时再从 arguments 的 input 字段还原(见 +// extractCustomToolCallInput)。 +const customToolInputSchema = `{"type":"object","properties":{"input":{"type":"string","description":"The raw input for this tool, passed through verbatim."}},"required":["input"]}` + +func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { + // 顶层 function/custom 工具名集合:namespace 子工具摊平后与其撞名时,chat + // 上游无法按 namespace 区分调用归属。这类请求在原生 Responses 上游是合法的 + // (按 namespace+name 路由),歧义由摊平转换制造且无法消除,必须显式拒绝, + // 不能静默降级(重复声明发给上游、回程还原到错误工具)。 + topLevel := make(map[string]bool) + for _, tool := range tools { + if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" { + topLevel[tool.Name] = true + } + } + flatOwner := make(map[string]NamespacedToolName) + toolSearchDeclared := false out := make([]ChatTool, 0, len(tools)) for _, tool := range tools { - if tool.Type != "function" { + switch tool.Type { + case "function": + out = append(out, ChatTool{ + Type: "function", + Function: &ChatFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.Parameters, + Strict: tool.Strict, + }, + }) + case "custom": + // codex 0.14x 的核心执行工具 exec 即为 custom 类型;丢弃它会让模型 + // 无法执行任何命令,必须降级为 function 工具透传。 + out = append(out, ChatTool{ + Type: "function", + Function: &ChatFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: json.RawMessage(customToolInputSchema), + }, + }) + case "tool_search": + // 代理不能改名(codex 的模型侧按 tool_search 这个名字调用),与客户端 + // 声明的同名工具无法区分——回程会把普通工具的调用劫持成 tool_search_call, + // 必须显式拒绝;重复声明 type=tool_search 去重即可。 + if topLevel[toolSearchProxyName] { + return nil, fmt.Errorf("built-in tool_search conflicts with a declared tool named %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName) + } + if toolSearchDeclared { + continue + } + toolSearchDeclared = true + out = append(out, toolSearchProxyChatTool()) + case "namespace": + flattened, err := namespaceChildrenToChatTools(tool, topLevel, flatOwner) + if err != nil { + return nil, err + } + out = append(out, flattened...) + } + // 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有 + // 对应能力,维持丢弃。 + } + return out, nil +} + +// toolSearchProxyName 是 tool_search 服务端工具降级后的 function 工具名。模型对 +// 它的调用以同名 function_call 原样回传,由 codex 端路由。 +const toolSearchProxyName = "tool_search" + +const toolSearchProxySchema = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}` + +func toolSearchProxyChatTool() ChatTool { + return ChatTool{ + Type: "function", + Function: &ChatFunction{ + Name: toolSearchProxyName, + Description: "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.", + Parameters: json.RawMessage(toolSearchProxySchema), + }, + } +} + +// namespaceChildrenToChatTools 将 namespace 工具的子 function 工具摊平为顶层 +// function 工具,名字加 "__" 前缀。摊平名与顶层工具或其他 namespace +// 撞名时返回错误(歧义不可消除,显式拒绝);同一 (namespace, 子工具) 的重复声明 +// 去重后不算冲突。 +func namespaceChildrenToChatTools(tool ResponsesTool, topLevel map[string]bool, flatOwner map[string]NamespacedToolName) ([]ChatTool, error) { + if tool.Name == "" { + return nil, nil + } + children := tool.Tools + if len(children) == 0 { + children = tool.Children + } + var out []ChatTool + for _, child := range children { + if child.Type != "function" || child.Name == "" { continue } + flat := flattenNamespaceToolName(tool.Name, child.Name) + entry := NamespacedToolName{Namespace: tool.Name, Name: child.Name} + if topLevel[flat] { + return nil, fmt.Errorf("namespace tool %q/%q flattens to %q which conflicts with a top-level tool of the same name; this upstream cannot disambiguate them, rename one of the tools", tool.Name, child.Name, flat) + } + if prev, ok := flatOwner[flat]; ok { + if prev == entry { + continue + } + return nil, fmt.Errorf("namespace tools %q/%q and %q/%q both flatten to %q; this upstream cannot disambiguate them, rename one of the tools", prev.Namespace, prev.Name, tool.Name, child.Name, flat) + } + flatOwner[flat] = entry out = append(out, ChatTool{ Type: "function", Function: &ChatFunction{ - Name: tool.Name, - Description: tool.Description, - Parameters: tool.Parameters, - Strict: tool.Strict, + Name: flat, + Description: child.Description, + Parameters: child.Parameters, + Strict: child.Strict, }, }) } - return out + return out, nil } -func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { +// chatToolNameMaxLen 是 Chat Completions function 工具名的通用长度上限。 +const chatToolNameMaxLen = 64 + +// flattenNamespaceToolName 生成 namespace 子工具的摊平名;超长时截断并追加 +// sha256 短哈希保证唯一性。 +func flattenNamespaceToolName(namespace, name string) string { + full := namespace + "__" + name + if len(full) <= chatToolNameMaxLen { + return full + } + sum := sha256.Sum256([]byte(full)) + suffix := "__" + hex.EncodeToString(sum[:4]) + prefixLen := chatToolNameMaxLen - len(suffix) + var prefix strings.Builder + for _, ch := range full { + if prefix.Len()+len(string(ch)) > prefixLen { + break + } + _, _ = prefix.WriteRune(ch) + } + return prefix.String() + suffix +} + +// 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 } - if rawString(choice["type"]) != "function" { - return raw + 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 } out, err := json.Marshal(map[string]any{ "type": "function", @@ -473,9 +758,38 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { return out } +// extractCustomToolCallInput 从降级 function 调用的 arguments 中还原 custom 工具的 +// 自由文本输入:优先取 {"input": "..."} 的 input 字段;模型未按 schema 输出时原样 +// 回传,交由客户端校验、模型重试。 +func extractCustomToolCallInput(arguments string) string { + trimmed := strings.TrimSpace(arguments) + if trimmed == "" { + return "" + } + var obj map[string]json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { + return trimmed + } + if raw, ok := obj["input"]; ok { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return trimmed + } + if len(obj) == 0 { + return "" + } + return trimmed +} + // ChatCompletionsResponseToResponses converts a non-streaming Chat Completions -// response into a Responses API response. -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string) *ResponsesResponse { +// response into a Responses API response. customTools 是客户端请求中 custom 工具 +// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项; +// toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 +// 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名 +// 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。 +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -500,7 +814,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -515,7 +829,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput { +func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput if message.ReasoningContent != "" { outputs = append(outputs, ResponsesOutput{ @@ -550,6 +864,39 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput { if strings.TrimSpace(arguments) == "" { arguments = "{}" } + if customTools[toolCall.Function.Name] { + outputs = append(outputs, ResponsesOutput{ + Type: "custom_tool_call", + ID: generateItemID(), + CallID: toolCall.ID, + Name: toolCall.Function.Name, + Input: extractCustomToolCallInput(arguments), + Status: "completed", + }) + 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 + } + if ns, ok := namespaceTools[toolCall.Function.Name]; ok { + outputs = append(outputs, ResponsesOutput{ + Type: "function_call", + ID: generateItemID(), + CallID: toolCall.ID, + Name: ns.Name, + Namespace: ns.Namespace, + Arguments: arguments, + Status: "completed", + }) + continue + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), @@ -563,6 +910,21 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput { 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", @@ -662,6 +1024,35 @@ type ChatCompletionsToResponsesStreamState struct { ToolItemIDs map[int]string ToolOutputIndex map[int]int + // CustomTools 是客户端请求中 custom/freeform 工具的名字集合(见 + // CustomToolNames)。命中的调用按 custom_tool_call 生命周期下发,codex 才能 + // 路由回它注册的 custom 工具。 + CustomTools map[string]bool + + // ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见 + // HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按 + // 该项类型(且 execution=client)执行 tool search。 + ToolSearchDeclared bool + + // NamespaceTools 是 namespace 子工具的摊平名 → 原始归属映射(见 + // NamespaceToolNames)。命中的调用还原为带 namespace 字段的 function_call 项, + // codex 按 namespace+name 路由。 + NamespaceTools map[string]NamespacedToolName + + // toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的 + // 项类型一致。 + toolIsCustom map[int]bool + + // toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。 + toolIsToolSearch map[int]bool + + // toolNamespace 记录工具调用宣告时命中的 namespace 归属(见 NamespaceTools)。 + toolNamespace map[int]NamespacedToolName + + // toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字 + // 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。 + toolAnnounced map[int]bool + FinishReason string Usage *ResponsesUsage } @@ -669,12 +1060,16 @@ 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), + 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), + toolNamespace: make(map[int]NamespacedToolName), + toolAnnounced: make(map[int]bool), } } @@ -758,19 +1153,8 @@ func ChatCompletionsChunkToResponsesEvents( copyCall.Function.Arguments = "" state.ToolCalls[idx] = ©Call stored = ©Call - itemID := generateItemID() - state.ToolItemIDs[idx] = itemID + state.ToolItemIDs[idx] = generateItemID() state.ToolOutputIndex[idx] = state.allocOutputIndex() - events = append(events, chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ - OutputIndex: state.ToolOutputIndex[idx], - Item: &ResponsesOutput{ - Type: "function_call", - ID: itemID, - CallID: stored.ID, - Name: stored.Function.Name, - Status: "in_progress", - }, - })) } else { if toolCall.ID != "" { stored.ID = toolCall.ID @@ -779,15 +1163,22 @@ func ChatCompletionsChunkToResponsesEvents( stored.Function.Name = toolCall.Function.Name } } + events = append(events, announceChatToolItem(state, idx, stored, false)...) if toolCall.Function.Arguments != "" { stored.Function.Arguments += toolCall.Function.Arguments - events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{ - OutputIndex: state.ToolOutputIndex[idx], - ItemID: state.ToolItemIDs[idx], - Delta: toolCall.Function.Arguments, - CallID: stored.ID, - Name: stored.Function.Name, - })) + // 未宣告(名字未到)时仅累积,宣告时统一补发;custom 调用的 + // arguments 是包裹 input 的 JSON 片段,无法增量还原为自由文本 + // 输入,缓冲整份 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], + Delta: toolCall.Function.Arguments, + CallID: stored.ID, + Name: stored.Function.Name, + })) + } } } if choice.FinishReason != nil && *choice.FinishReason != "" { @@ -998,6 +1389,64 @@ func ensureChatToResponsesTextPart(state *ChatCompletionsToResponsesStreamState) })} } +// announceChatToolItem 在类型可判定时发出工具调用的 output_item.added。custom +// 工具的判定依赖名字:名字未到且请求里存在 custom 工具时延迟宣告,避免 added/done +// 的项类型不一致;force 用于流收尾,名字始终未到时按 function_call 兜底。 +func announceChatToolItem( + state *ChatCompletionsToResponsesStreamState, + idx int, + stored *ChatToolCall, + force bool, +) []ResponsesStreamEvent { + if state.toolAnnounced[idx] { + return nil + } + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { + 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" + } + // namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要 + // 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。 + itemName, itemNamespace := stored.Function.Name, "" + if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch { + state.toolNamespace[idx] = ns + itemName, itemNamespace = ns.Name, ns.Namespace + } + events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ + OutputIndex: state.ToolOutputIndex[idx], + Item: &ResponsesOutput{ + Type: itemType, + ID: state.ToolItemIDs[idx], + CallID: stored.ID, + Name: itemName, + Namespace: itemNamespace, + Status: "in_progress", + }, + })} + // 迟到宣告时补发已累积的参数增量(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], + Delta: stored.Function.Arguments, + CallID: stored.ID, + Name: stored.Function.Name, + })) + } + return events +} + // closeChatToolItems emits function_call_arguments.done + output_item.done for // every tool call opened during the stream, carrying the full call_id/name/ // arguments so codex can deserialize and execute the call. Mirrors cc-switch's @@ -1016,17 +1465,72 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response if !opened { continue } + // 名字始终未到导致尚未宣告的调用,收尾前按最终名字兜底宣告。 + events = append(events, announceChatToolItem(state, i, toolCall, true)...) arguments := toolCall.Function.Arguments if strings.TrimSpace(arguments) == "" { arguments = "{}" } outputIndex := state.ToolOutputIndex[i] + if state.toolIsCustom[i] { + // custom 调用按 custom_tool_call 生命周期收尾:input 在此处一次性下发 + // (流中不产出增量,见 ChatCompletionsChunkToResponsesEvents)。 + input := extractCustomToolCallInput(arguments) + if input != "" { + events = append(events, chatToResponsesEvent(state, "response.custom_tool_call_input.delta", &ResponsesStreamEvent{ + OutputIndex: outputIndex, + ItemID: itemID, + Delta: input, + })) + } + events = append(events, + chatToResponsesEvent(state, "response.custom_tool_call_input.done", &ResponsesStreamEvent{ + OutputIndex: outputIndex, + ItemID: itemID, + CallID: toolCall.ID, + Name: toolCall.Function.Name, + Input: input, + }), + chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ + OutputIndex: outputIndex, + Item: &ResponsesOutput{ + Type: "custom_tool_call", + ID: itemID, + CallID: toolCall.ID, + Name: toolCall.Function.Name, + Input: input, + Status: "completed", + }, + }), + ) + 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 + } + // namespace 子工具调用在宣告时已记录归属,收尾项同样带还原名与 namespace。 + name, namespace := toolCall.Function.Name, "" + if ns, ok := state.toolNamespace[i]; ok { + name, namespace = ns.Name, ns.Namespace + } events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ OutputIndex: outputIndex, ItemID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, Arguments: arguments, }), chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ @@ -1035,7 +1539,8 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response Type: "function_call", ID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, + Namespace: namespace, Arguments: arguments, Status: "completed", }, @@ -1078,11 +1583,37 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp if strings.TrimSpace(arguments) == "" { arguments = "{}" } + if state.toolIsCustom[i] { + outputs = append(outputs, ResponsesOutput{ + Type: "custom_tool_call", + ID: generateItemID(), + CallID: toolCall.ID, + Name: toolCall.Function.Name, + Input: extractCustomToolCallInput(arguments), + Status: "completed", + }) + continue + } + if state.toolIsToolSearch[i] { + outputs = append(outputs, ResponsesOutput{ + Type: "tool_search_call", + ID: generateItemID(), + CallID: toolCall.ID, + Arguments: arguments, + Status: "completed", + }) + continue + } + name, namespace := toolCall.Function.Name, "" + if ns, ok := state.toolNamespace[i]; ok { + name, namespace = ns.Name, ns.Namespace + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: name, + Namespace: namespace, Arguments: arguments, Status: "completed", }) 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 new file mode 100644 index 0000000000..9271b50d1d --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -0,0 +1,878 @@ +package apicompat + +// custom/freeform 工具(如 Codex 0.14x 的 exec)在 responses→chat 桥上的双向转换。 +// 背景:Codex 的核心命令执行工具 exec 是 type=custom(输入为自由文本),此前被 +// responsesToolsToChatTools 丢弃,导致模型工具列表中没有 exec、无法执行任何命令。 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *testing.T) { + req := &ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"run dir"`), + Tools: []ResponsesTool{ + {Type: "custom", Name: "exec", Description: "Run JavaScript code"}, + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + }, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, out.Tools, 2) + + assert.Equal(t, "function", out.Tools[0].Type) + assert.Equal(t, "exec", out.Tools[0].Function.Name) + assert.Equal(t, "Run JavaScript code", out.Tools[0].Function.Description) + assert.JSONEq(t, customToolInputSchema, string(out.Tools[0].Function.Parameters)) + + assert.Equal(t, "wait", out.Tools[1].Function.Name) +} + +func TestResponsesToChatCompletionsRequest_DropsToolChoiceWhenNoConvertibleTools(t *testing.T) { + req := &ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "web_search"}, + {Type: "image_generation"}, + }, + ToolChoice: json.RawMessage(`"auto"`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + + assert.Empty(t, out.Tools) + assert.Empty(t, out.ToolChoice, "tools 为空时转发 tool_choice 会被上游 400 拒绝") +} + +func TestResponsesToChatCompletionsRequest_CustomToolChoiceMapsToFunctionChoice(t *testing.T) { + req := &ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"run dir"`), + Tools: []ResponsesTool{{Type: "custom", Name: "exec"}}, + ToolChoice: json.RawMessage(`{"type":"custom","name":"exec"}`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + + assert.JSONEq(t, `{"type":"function","function":{"name":"exec"}}`, string(out.ToolChoice)) +} + +func TestResponsesInputToChatMessages_CustomToolCallHistory(t *testing.T) { + input := json.RawMessage(`[ + {"role":"user","content":"list files"}, + {"type":"custom_tool_call","call_id":"call_1","name":"exec","input":"dir"}, + {"type":"custom_tool_call_output","call_id":"call_1","output":"main.go"} + ]`) + + messages, err := responsesInputToChatMessages("", input) + require.NoError(t, err) + require.Len(t, messages, 3) + + assert.Equal(t, []string{"user", "assistant", "tool"}, chatMessageRoles(messages)) + + require.Len(t, messages[1].ToolCalls, 1) + toolCall := messages[1].ToolCalls[0] + assert.Equal(t, "call_1", toolCall.ID) + assert.Equal(t, "exec", toolCall.Function.Name) + assert.JSONEq(t, `{"input":"dir"}`, toolCall.Function.Arguments) + + assert.Equal(t, "call_1", messages[2].ToolCallID) + assert.JSONEq(t, `"main.go"`, string(messages[2].Content)) +} + +func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.T) { + resp := &ChatCompletionsResponse{ + ID: "cc-1", + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_1", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`}}, + {ID: "call_2", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}}, + }, + }, + }}, + } + + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil) + require.Len(t, out.Output, 2) + + assert.Equal(t, "custom_tool_call", out.Output[0].Type) + assert.Equal(t, "call_1", out.Output[0].CallID) + assert.Equal(t, "exec", out.Output[0].Name) + assert.Equal(t, "dir", out.Output[0].Input) + assert.Empty(t, out.Output[0].Arguments) + + assert.Equal(t, "function_call", out.Output[1].Type) + assert.Equal(t, "wait", out.Output[1].Name) + assert.Equal(t, `{"cell_id": 3}`, out.Output[1].Arguments) +} + +func TestExtractCustomToolCallInput_FallsBackToRawArguments(t *testing.T) { + assert.Equal(t, "dir", extractCustomToolCallInput(`{"input": "dir"}`)) + assert.Equal(t, "console.log(1)", extractCustomToolCallInput(`console.log(1)`)) + assert.Equal(t, `{"other": "x"}`, extractCustomToolCallInput(`{"other": "x"}`)) + assert.Equal(t, "", extractCustomToolCallInput(`{}`)) + assert.Equal(t, "", extractCustomToolCallInput("")) +} + +func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.CustomTools = map[string]bool{"exec": true} + + idx := 0 + chunk := &ChatCompletionsChunk{ + ID: "cc-1", + Choices: []ChatChunkChoice{{ + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + ID: "call_1", + Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`}, + }}, + }, + }}, + } + + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + var added, inputDone, 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.custom_tool_call_input.done": + inputDone = evt + case "response.output_item.done": + if evt.Item != nil && evt.Item.Type == "custom_tool_call" { + itemDone = evt + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + t.Fatalf("custom 工具调用不应产出 function_call 参数事件: %s", evt.Type) + } + } + + require.NotNil(t, added, "缺少 custom_tool_call 的 output_item.added") + assert.Equal(t, "custom_tool_call", added.Item.Type) + assert.Equal(t, "exec", added.Item.Name) + + require.NotNil(t, inputDone, "缺少 response.custom_tool_call_input.done") + assert.Equal(t, "dir", inputDone.Input) + assert.Equal(t, "call_1", inputDone.CallID) + + require.NotNil(t, itemDone, "缺少 custom_tool_call 的 output_item.done") + assert.Equal(t, "call_1", itemDone.Item.CallID) + assert.Equal(t, "exec", itemDone.Item.Name) + assert.Equal(t, "dir", itemDone.Item.Input) + assert.Empty(t, itemDone.Item.Arguments) + + // response.completed 的 output 数组同样携带 custom_tool_call 项。 + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.NotNil(t, final.Response) + foundCustom := false + for _, item := range final.Response.Output { + if item.Type == "custom_tool_call" { + foundCustom = true + assert.Equal(t, "exec", item.Name) + assert.Equal(t, "dir", item.Input) + } + } + assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项") +} + +func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) { + req := &ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "tool_search"}}, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, out.Tools, 1) + + assert.Equal(t, "function", out.Tools[0].Type) + assert.Equal(t, "tool_search", out.Tools[0].Function.Name) + 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, nil) + 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, nil) + 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", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{ + Type: "namespace", + Name: "gmail", + Tools: []ResponsesTool{ + {Type: "function", Name: "send", Description: "Send mail", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + {Type: "custom", Name: "ignored_child"}, + }, + }}, + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, out.Tools, 1, "namespace 子工具中仅 function 类型被摊平") + + assert.Equal(t, "gmail__send", out.Tools[0].Function.Name) + assert.Equal(t, "Send mail", out.Tools[0].Function.Description) +} + +func TestResponsesToolsParsing_StringToolBecomesCustom(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":["exec",{"type":"function","name":"wait"}]}`), &req)) + + require.Len(t, req.Tools, 2) + assert.Equal(t, "custom", req.Tools[0].Type) + assert.Equal(t, "exec", req.Tools[0].Name) + assert.Equal(t, "function", req.Tools[1].Type) + + assert.True(t, CustomToolNames(req.Tools)["exec"]) +} + +func TestFlattenNamespaceToolName_CapsAt64WithHashSuffix(t *testing.T) { + assert.Equal(t, "gmail__send", flattenNamespaceToolName("gmail", "send")) + + long := flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too") + assert.LessOrEqual(t, len(long), 64) + assert.Contains(t, long, "__") + // 同输入结果稳定 + assert.Equal(t, long, flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too")) +} + +func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) { + input := json.RawMessage(`[ + {"role":"user","content":"find tools"}, + {"type":"tool_search_call","call_id":"call_s","arguments":{"query":"gmail"}}, + {"type":"tool_search_output","call_id":"call_s","output":{"groups":["gmail"]}} + ]`) + + messages, err := responsesInputToChatMessages("", input) + require.NoError(t, err) + require.Len(t, messages, 3) + + require.Len(t, messages[1].ToolCalls, 1) + assert.Equal(t, "tool_search", messages[1].ToolCalls[0].Function.Name) + assert.JSONEq(t, `{"query":"gmail"}`, messages[1].ToolCalls[0].Function.Arguments) + + assert.Equal(t, "tool", messages[2].Role) + assert.Equal(t, "call_s", messages[2].ToolCallID) + assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content)) +} + +func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) { + input := json.RawMessage(`[ + {"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"}, + {"type":"function_call_output","call_id":"call_n","output":"ok"} + ]`) + + messages, err := responsesInputToChatMessages("", input) + require.NoError(t, err) + require.Len(t, messages, 2) + + require.Len(t, messages[0].ToolCalls, 1) + assert.Equal(t, "gmail__send", messages[0].ToolCalls[0].Function.Name) +} + +func TestChatCompletionsChunkToResponsesEvents_CustomToolNameArrivesLate(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.CustomTools = map[string]bool{"exec": true} + + idx := 0 + chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_1", Function: ChatFunctionCall{Arguments: `{"inp`}}}, + }}}} + chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "exec", Arguments: `ut": "dir"}`}}}, + }}}} + + var events []ResponsesStreamEvent + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...) + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + addedCount := 0 + for _, evt := range events { + switch evt.Type { + case "response.output_item.added": + if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" { + addedCount++ + assert.Equal(t, "custom_tool_call", evt.Item.Type, "迟到的名字命中 custom 工具时按 custom_tool_call 宣告") + assert.Equal(t, "exec", evt.Item.Name) + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + t.Fatalf("custom 调用不应产出 function 参数事件: %s", evt.Type) + case "response.custom_tool_call_input.done": + assert.Equal(t, "dir", evt.Input) + } + } + assert.Equal(t, 1, addedCount, "工具调用只宣告一次") +} + +func TestChatCompletionsChunkToResponsesEvents_FunctionToolNameArrivesLate(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.CustomTools = map[string]bool{"exec": true} + + idx := 0 + chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_9", Function: ChatFunctionCall{Arguments: `{"cell`}}}, + }}}} + chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "wait", Arguments: `_id": 3}`}}}, + }}}} + + var events []ResponsesStreamEvent + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...) + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + deltas := "" + argsDone := "" + for _, evt := range events { + switch evt.Type { + case "response.function_call_arguments.delta": + deltas += evt.Delta + case "response.function_call_arguments.done": + argsDone = evt.Arguments + case "response.custom_tool_call_input.done": + t.Fatal("function 调用不应产出 custom 事件") + } + } + assert.Equal(t, `{"cell_id": 3}`, deltas, "宣告前累积的参数需在宣告时补发") + assert.Equal(t, `{"cell_id": 3}`, argsDone) +} + +// 序列化层(MarshalJSON → responsesItemWire)单独走白名单重组,事件结构体上的字段 +// 齐全不代表落到 SSE 线上的 JSON 齐全,必须在 wire 层再断言一次。 +func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) { + evt := ResponsesStreamEvent{ + Type: "response.output_item.done", + OutputIndex: 1, + Item: &ResponsesOutput{ + Type: "custom_tool_call", + ID: "item_1", + CallID: "call_1", + Name: "exec", + Input: "dir", + Status: "completed", + }, + } + + sse, err := ResponsesEventToSSE(evt) + require.NoError(t, err) + + assert.Contains(t, sse, `"call_id":"call_1"`) + assert.Contains(t, sse, `"name":"exec"`) + assert.Contains(t, sse, `"input":"dir"`) + assert.Contains(t, sse, `"type":"custom_tool_call"`) +} + +func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) { + tools := []ResponsesTool{ + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{ + {Type: "function", Name: "send"}, + {Type: "custom", Name: "skip_me"}, + }}, + {Type: "namespace", Name: "crm", Children: []ResponsesTool{ + {Type: "function", Name: "query"}, + }}, + {Type: "function", Name: "wait"}, + } + + m := NamespaceToolNames(tools) + require.Len(t, m, 2) + assert.Equal(t, NamespacedToolName{Namespace: "gmail", Name: "send"}, m["gmail__send"]) + assert.Equal(t, NamespacedToolName{Namespace: "crm", Name: "query"}, m["crm__query"]) + + // 摊平名超长时截断加哈希,无法按字符串切分还原,必须经映射反查。 + longNS := "very_long_namespace_prefix_for_testing_purposes" + longChild := "and_a_rather_long_tool_name_too" + m2 := NamespaceToolNames([]ResponsesTool{{ + Type: "namespace", Name: longNS, + Tools: []ResponsesTool{{Type: "function", Name: longChild}}, + }}) + assert.Equal(t, NamespacedToolName{Namespace: longNS, Name: longChild}, + m2[flattenNamespaceToolName(longNS, longChild)]) + + assert.Nil(t, NamespaceToolNames(nil)) +} + +// 内置 tool_search 降级后的代理 function 与客户端声明的同名工具无法区分:回程会把 +// 普通工具的调用劫持成 tool_search_call,必须显式拒绝(代理不能改名,codex 的模型 +// 侧按 tool_search 这个名字调用)。 +func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *testing.T) { + // 与顶层 function 工具同名。 + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "tool_search"}, + {Type: "function", Name: "tool_search"}, + }, + }) + require.Error(t, err, "与内置 tool_search 代理撞名的 function 工具必须拒绝") + assert.Contains(t, err.Error(), "tool_search") + + // 与顶层 custom 工具同名。 + _, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "custom", Name: "tool_search"}, + {Type: "tool_search"}, + }, + }) + require.Error(t, err, "与内置 tool_search 代理撞名的 custom 工具必须拒绝") + + // 重复声明 type=tool_search 去重后只产出一个代理,不拒绝。 + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "tool_search"}, {Type: "tool_search"}}, + }) + require.NoError(t, err) + require.Len(t, out.Tools, 1) + 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)) +} + +// 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),而不是 +// 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。 +func TestResponsesToChatCompletionsRequest_RejectsAmbiguousFlattenedNames(t *testing.T) { + // 摊平名与顶层 function 工具撞名。 + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "function", Name: "gmail__send"}, + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{{Type: "function", Name: "send"}}}, + }, + }) + require.Error(t, err, "与顶层工具撞名的摊平必须拒绝") + assert.Contains(t, err.Error(), "gmail__send") + + // 不同 namespace 组合产生相同摊平名。 + _, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "namespace", Name: "a", Tools: []ResponsesTool{{Type: "function", Name: "b__c"}}}, + {Type: "namespace", Name: "a__b", Tools: []ResponsesTool{{Type: "function", Name: "c"}}}, + }, + }) + require.Error(t, err, "跨 namespace 撞名的摊平必须拒绝") + assert.Contains(t, err.Error(), "a__b__c") +} + +// 完全相同的 (namespace, 子工具) 重复声明不构成歧义:去重后正常转换,不拒绝。 +func TestResponsesToChatCompletionsRequest_DedupesIdenticalNamespaceChildren(t *testing.T) { + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "namespace", Name: "gmail", Tools: []ResponsesTool{ + {Type: "function", Name: "send"}, + {Type: "function", Name: "send"}, + }}, + }, + }) + require.NoError(t, err) + require.Len(t, out.Tools, 1, "重复声明的同一子工具只声明一次") + assert.Equal(t, "gmail__send", out.Tools[0].Function.Name) +} + +// codex 按 namespace+name 路由 namespace 子工具的调用:回程必须把摊平名还原为 +// 裸子工具名并带独立 namespace 字段,平铺名的 function_call 会被 codex 判为 +// unsupported call 拒绝执行。 +func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testing.T) { + resp := &ChatCompletionsResponse{ + ID: "cc-1", + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_n", Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}}, + {ID: "call_9", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}}, + }, + }, + }}, + } + nsTools := map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools) + require.Len(t, out.Output, 2) + + item := out.Output[0] + assert.Equal(t, "function_call", item.Type) + assert.Equal(t, "echo", item.Name) + assert.Equal(t, "mcp__svc", item.Namespace) + assert.Equal(t, "call_n", item.CallID) + assert.Equal(t, `{"text":"hi"}`, item.Arguments) + + // 非流式响应体走 ResponsesOutput.MarshalJSON,namespace 必须落到线上 JSON。 + b, err := json.Marshal(item) + require.NoError(t, err) + assert.Contains(t, string(b), `"namespace":"mcp__svc"`) + assert.Contains(t, string(b), `"name":"echo"`) + + // 未命中映射的普通 function 调用不受影响,且不携带 namespace 字段。 + assert.Equal(t, "wait", out.Output[1].Name) + assert.Empty(t, out.Output[1].Namespace) + b2, err := json.Marshal(out.Output[1]) + require.NoError(t, err) + assert.NotContains(t, string(b2), `"namespace"`) +} + +func TestChatCompletionsChunkToResponsesEvents_NamespacedToolCallStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.NamespaceTools = map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{ + ID: "cc-1", + Choices: []ChatChunkChoice{{ + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + ID: "call_n", + Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `{"text":"hi"}`}, + }}, + }, + }}, + } + + 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 == "function_call" { + itemDone = evt + } + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + t.Fatalf("namespace 子工具调用不应产出 custom 事件: %s", evt.Type) + } + } + + require.NotNil(t, added, "缺少 namespace 调用的 output_item.added") + assert.Equal(t, "function_call", added.Item.Type) + assert.Equal(t, "echo", added.Item.Name) + assert.Equal(t, "mcp__svc", added.Item.Namespace) + + require.NotNil(t, itemDone, "缺少 namespace 调用的 output_item.done") + assert.Equal(t, "call_n", itemDone.Item.CallID) + assert.Equal(t, "echo", itemDone.Item.Name) + assert.Equal(t, "mcp__svc", itemDone.Item.Namespace) + assert.Equal(t, `{"text":"hi"}`, itemDone.Item.Arguments) + + // SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言 namespace 落线。 + sse, err := ResponsesEventToSSE(*itemDone) + require.NoError(t, err) + assert.Contains(t, sse, `"namespace":"mcp__svc"`) + assert.Contains(t, sse, `"name":"echo"`) + assert.Contains(t, sse, `"call_id":"call_n"`) + + // response.completed 的 output 数组同样携带还原后的 namespace 调用项。 + 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 == "function_call" { + found = true + assert.Equal(t, "echo", item.Name) + assert.Equal(t, "mcp__svc", item.Namespace) + } + } + assert.True(t, found, "response.completed 缺少还原后的 namespace 调用项") +} + +func TestChatCompletionsChunkToResponsesEvents_NamespacedToolNameArrivesLate(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.NamespaceTools = map[string]NamespacedToolName{ + "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, + } + + idx := 0 + chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_n", Function: ChatFunctionCall{Arguments: `{"te`}}}, + }}}} + chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "mcp__svc__echo", Arguments: `xt":"hi"}`}}}, + }}}} + + var events []ResponsesStreamEvent + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...) + events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + addedCount := 0 + deltas := "" + for _, evt := range events { + switch evt.Type { + case "response.output_item.added": + if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" { + addedCount++ + assert.Equal(t, "echo", evt.Item.Name, "迟到的名字命中 namespace 映射时按还原名宣告") + assert.Equal(t, "mcp__svc", evt.Item.Namespace) + } + case "response.function_call_arguments.delta": + deltas += evt.Delta + } + } + assert.Equal(t, 1, addedCount, "工具调用只宣告一次") + assert.Equal(t, `{"text":"hi"}`, deltas, "宣告前累积的参数需在宣告时补发") +} + +func TestChatCompletionsChunkToResponsesEvents_FunctionToolStreamUnaffected(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.CustomTools = map[string]bool{"exec": true} + + idx := 0 + chunk := &ChatCompletionsChunk{ + Choices: []ChatChunkChoice{{ + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + ID: "call_9", + Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}, + }}, + }, + }}, + } + + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + sawArgsDelta := false + for _, evt := range events { + if evt.Type == "response.function_call_arguments.delta" { + sawArgsDelta = true + } + if evt.Type == "response.custom_tool_call_input.done" { + t.Fatal("function 工具不应产出 custom_tool_call 事件") + } + } + assert.True(t, sawArgsDelta, "function 工具应保持原有参数增量事件") +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 4075f57791..4e319f9751 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -459,7 +459,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner") + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -493,7 +493,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner") + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/responses_stream_event_wire.go b/backend/internal/pkg/apicompat/responses_stream_event_wire.go index df7a82e393..c2ebcd3eee 100644 --- a/backend/internal/pkg/apicompat/responses_stream_event_wire.go +++ b/backend/internal/pkg/apicompat/responses_stream_event_wire.go @@ -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. @@ -167,6 +184,23 @@ func responsesItemWire(item *ResponsesOutput) map[string]any { m["call_id"] = item.CallID m["name"] = item.Name m["arguments"] = item.Arguments + // namespace 子工具的还原调用:codex 按 namespace+name 路由,缺少该字段 + // 会被判为 unsupported call。 + if item.Namespace != "" { + m["namespace"] = item.Namespace + } + case "custom_tool_call": + // custom/freeform 工具调用(如 codex 的 exec):input 为自由文本。缺少 + // call_id/name 时 codex 无法路由该调用(表现为 unsupported call)。 + 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 } diff --git a/backend/internal/pkg/apicompat/responses_stream_event_wire_test.go b/backend/internal/pkg/apicompat/responses_stream_event_wire_test.go index b4f6871d5f..f44f3e7770 100644 --- a/backend/internal/pkg/apicompat/responses_stream_event_wire_test.go +++ b/backend/internal/pkg/apicompat/responses_stream_event_wire_test.go @@ -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) { diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 8d96a1d3d3..9f3f2daa66 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -249,11 +249,31 @@ type ResponsesContentPart struct { // ResponsesTool describes a tool in the Responses API. type ResponsesTool struct { - Type string `json:"type"` // "function" | "web_search" | "local_shell" etc. + Type string `json:"type"` // "function" | "custom" | "web_search" | "local_shell" etc. Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` Strict *bool `json:"strict,omitempty"` + + // type=namespace 的子工具列表(tools 与 children 二选一,语义相同)。 + Tools []ResponsesTool `json:"tools,omitempty"` + Children []ResponsesTool `json:"children,omitempty"` +} + +// UnmarshalJSON 容忍字符串形式的工具声明:codex 会以 "name" 简写声明 custom 工具, +func (t *ResponsesTool) UnmarshalJSON(data []byte) error { + var name string + if err := json.Unmarshal(data, &name); err == nil { + *t = ResponsesTool{Type: "custom", Name: name} + return nil + } + type alias ResponsesTool + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *t = ResponsesTool(a) + return nil } // ResponsesResponse is the non-streaming response from POST /v1/responses. @@ -301,11 +321,38 @@ type ResponsesOutput struct { CallID string `json:"call_id,omitempty"` Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` + // 来源为 namespace 子工具时的归属命名空间(codex 按 namespace+name 路由该调用)。 + Namespace string `json:"namespace,omitempty"` + + // type=custom_tool_call(custom/freeform 工具,input 为自由文本) + Input string `json:"input,omitempty"` // type=web_search_call 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" @@ -444,6 +491,9 @@ type ResponsesStreamEvent struct { Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` + // response.custom_tool_call_input.done + Input string `json:"input,omitempty"` + // response.reasoning_summary_text.delta / done // Reuses Text/Delta fields above, SummaryIndex identifies which summary part SummaryIndex int `json:"summary_index,omitempty"` diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index f66fbc9ada..3861ae9945 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -140,7 +140,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, nil, false, nil) anthropicResp := apicompat.ResponsesToAnthropic(responsesResp, originalModel) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 247c195f24..9d3391e4e6 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -39,6 +39,13 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( clientStream := responsesReq.Stream serviceTier := extractOpenAIServiceTierFromBody(body) + // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 + // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 + // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原 + // 为带 namespace 字段的 function_call 项。 + customTools := apicompat.CustomToolNames(responsesReq.Tools) + toolSearch := apicompat.HasToolSearchTool(responsesReq.Tools) + namespaceTools := apicompat.NamespaceToolNames(responsesReq.Tools) chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) if err != nil { @@ -100,15 +107,18 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( c *gin.Context, resp *http.Response, originalModel string, + customTools map[string]bool, + toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -120,7 +130,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -144,6 +154,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c *gin.Context, resp *http.Response, originalModel string, + customTools map[string]bool, + toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -154,6 +167,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header) state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) + state.CustomTools = customTools + state.ToolSearchDeclared = toolSearch + state.NamespaceTools = namespaceTools clientDisconnected := false writeEvents := func(events []apicompat.ResponsesStreamEvent) {