From 75fb3c41c272163e02970d23df6c793f1519acf1 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Tue, 7 Jul 2026 16:23:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(apicompat):=20responses=E2=86=92chat=20?= =?UTF-8?q?=E6=A1=A5=E6=94=AF=E6=8C=81=20custom=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Codex=20exec=20=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 0.14x 的核心执行工具 exec 是 type=custom(freeform)工具, responsesToolsToChatTools 丢弃所有非 function 工具,导致模型工具列表中 没有 exec、无法执行任何命令;SSE 序列化层 responsesItemWire 也缺少 custom_tool_call 分支,即使产出该类型 item,call_id/name/input 也会被 白名单剥离。 - custom 工具降级为单一 input:string 参数的 function 工具 - 回程(非流式+流式)按名字还原 custom_tool_call 项/事件 - 历史 custom_tool_call / custom_tool_call_output 项转换(此前被跳过) - responsesItemWire 补 custom_tool_call 分支 - tools 全部被丢弃时不再转发 tool_choice(修复上游 400 "'tool_choice' is only allowed when 'tools' are specified") feat(apicompat): chat 桥补齐 tool_search/namespace/字符串工具声明 对齐参考实现(cc-switch transform_codex_chat.rs)的工具类型覆盖: - tool_search → 固定 function 代理工具(query 必填/limit 可选), 历史项 tool_search_call/tool_search_output 一并转换 - namespace → 子 function 工具摊平为 __, 超 64 字符截断加 sha256 短哈希;历史项带 namespace 字段时同规则摊平 - tools: ["name"] 字符串简写按 custom 工具解析 fix(apicompat): 工具调用宣告延迟到类型可判定,兼容 name 迟到的上游 output_item.added 的 custom/function 判定依赖工具名。名字晚于首个分片 到达时,此前会按 function_call 宣告造成 added/done 项类型不一致。 改为存在 custom 工具且名字未到时延迟宣告,宣告时补发已累积的参数增量; 请求无 custom 工具时行为不变。 PR 标题与正文(可直接粘贴) 标题:fix(apicompat): responses→chat 桥丢弃 custom 工具导致 Codex 无法执行命令 正文: ## 问题 OpenAI APIKey 账号接入仅支持 /v1/chat/completions 的第三方上游 (DeepSeek/GLM/Kimi 等)时,Codex 0.14x 客户端文本对话正常,但无法执行 任何命令:exec 调用全部 "aborted",spawn_agent 派出的子智能体永远卡在 running,多轮后模型自述"环境无法执行文件系统命令"放弃。 ## 根因 Codex 0.14x 的核心执行工具 exec 是 Responses API 的 custom(freeform) 工具,而非普通 function: {"type": "custom", "name": "exec", "format": {"type": "grammar", ...}} 两处缺陷叠加: 1. `responsesToolsToChatTools` 丢弃所有非 function 工具 (chatcompletions_responses_bridge.go),模型工具列表中从未出现 exec; 2. SSE 序列化 `responsesItemWire` 按 item 类型白名单重组字段,无 custom_tool_call 分支,call_id/name/input 会被剥离 (responses_stream_event_wire.go)。 次生缺陷:工具全部被丢弃时 tool_choice 仍被转发,部分上游报 400 "'tool_choice' is only allowed when 'tools' are specified"。 ## 改动 (三个提交的要点清单,见各提交说明) 设计与 cc-switch 的 Codex chat 转换层(transform_codex_chat.rs / streaming_codex_chat.rs)对齐:custom 工具降级为 {"input": string} function 工具;custom 调用的参数增量不可流式还原为自由文本,缓冲至 收尾一次性下发 custom_tool_call_input.delta/done。 ## 测试 - 新增 17 个单元测试:请求工具转换、tool_choice 兜底、历史项转换、 非流式/流式回程还原、wire 序列化字段完整性、name 迟到宣告、命名摊平 - `go test ./internal/pkg/apicompat/...` 与 service 相关测试全绿 - 真机验证:Codex CLI 0.142.5 → 本桥 → GLM/DeepSeek 系 chat-only 上游, exec 实际执行命令并多轮续作正常 ## 影响面 仅影响 "APIKey 账号 + 判定不支持 Responses" 的 chat 直转路径: - OAuth 账号与透传路径不经过本桥(透传为字节级原文转发) - CC→Responses 反方向仅构造 ResponsesRequest,新增字段带 omitempty 且不赋值,序列化输出不变 - 探测逻辑未动,路由判定不变 - 共享类型改动均为增量容错(新字段 omitempty;UnmarshalJSON 只新增 字符串工具的解析成功路径) --- .../chatcompletions_responses_bridge.go | 405 ++++++++++++++--- ...ions_responses_bridge_custom_tools_test.go | 414 ++++++++++++++++++ .../chatcompletions_responses_test.go | 4 +- .../apicompat/responses_stream_event_wire.go | 6 + backend/internal/pkg/apicompat/types.go | 28 +- .../openai_gateway_responses_chat_fallback.go | 12 +- 6 files changed, 812 insertions(+), 57 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f0570b58ec..1567077cbb 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" @@ -35,13 +37,30 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR if len(req.Tools) > 0 { out.Tools = responsesToolsToChatTools(req.Tools) } - if len(req.ToolChoice) > 0 { + // tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发 + // tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。 + if len(out.Tools) > 0 && len(req.ToolChoice) > 0 { out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) } 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 +} + // responsesInputToChatMessages converts a Responses request's instructions + // input[] into Chat Completions messages. It is a three-stage pipeline: // @@ -129,33 +148,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"]), @@ -180,9 +234,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 = "" @@ -209,6 +263,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 @@ -423,31 +496,123 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra } } +// 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 { 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": + out = append(out, toolSearchProxyChatTool()) + case "namespace": + out = append(out, namespaceChildrenToChatTools(tool)...) + } + // 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有 + // 对应能力,维持丢弃。 + } + return out +} + +// 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 工具,名字加 "__" 前缀。 +func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool { + if tool.Name == "" { + return 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 } out = append(out, ChatTool{ Type: "function", Function: &ChatFunction{ - Name: tool.Name, - Description: tool.Description, - Parameters: tool.Parameters, - Strict: tool.Strict, + Name: flattenNamespaceToolName(tool.Name, child.Name), + Description: child.Description, + Parameters: child.Parameters, + Strict: child.Strict, }, }) } return out } +// 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 +} + func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { var choice map[string]json.RawMessage if err := json.Unmarshal(raw, &choice); err != nil { return raw } - if rawString(choice["type"]) != "function" { + // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 + if t := rawString(choice["type"]); t != "function" && t != "custom" { return raw } name := rawString(choice["name"]) @@ -469,9 +634,35 @@ 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 项。 +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -496,7 +687,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) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -511,7 +702,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput { +func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool) []ResponsesOutput { var outputs []ResponsesOutput if message.ReasoningContent != "" { outputs = append(outputs, ResponsesOutput{ @@ -546,6 +737,17 @@ 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 + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), @@ -650,6 +852,19 @@ type ChatCompletionsToResponsesStreamState struct { ToolItemIDs map[int]string ToolOutputIndex map[int]int + // CustomTools 是客户端请求中 custom/freeform 工具的名字集合(见 + // CustomToolNames)。命中的调用按 custom_tool_call 生命周期下发,codex 才能 + // 路由回它注册的 custom 工具。 + CustomTools map[string]bool + + // toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的 + // 项类型一致。 + toolIsCustom map[int]bool + + // toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字 + // 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。 + toolAnnounced map[int]bool + FinishReason string Usage *ResponsesUsage } @@ -663,6 +878,8 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe 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), } } @@ -746,19 +963,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 @@ -767,15 +973,21 @@ 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)。 + if state.toolAnnounced[idx] && !state.toolIsCustom[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 != "" { @@ -986,6 +1198,51 @@ 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 { + return nil + } + state.toolAnnounced[idx] = true + isCustom := state.CustomTools[stored.Function.Name] + state.toolIsCustom[idx] = isCustom + itemType := "function_call" + if isCustom { + itemType = "custom_tool_call" + } + 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: stored.Function.Name, + Status: "in_progress", + }, + })} + // 迟到宣告时补发已累积的参数增量(custom 工具的输入收尾统一下发,不补发)。 + if !isCustom && 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 @@ -1004,11 +1261,46 @@ 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 + } events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ OutputIndex: outputIndex, @@ -1066,6 +1358,17 @@ 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 + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), 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..f7828a2d00 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -0,0 +1,414 @@ +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}) + 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"`) +} + +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 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 795a73938e..a703f0052b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -362,7 +362,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner") + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil) 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") + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", 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..6eaead4459 100644 --- a/backend/internal/pkg/apicompat/responses_stream_event_wire.go +++ b/backend/internal/pkg/apicompat/responses_stream_event_wire.go @@ -167,6 +167,12 @@ func responsesItemWire(item *ResponsesOutput) map[string]any { m["call_id"] = item.CallID m["name"] = item.Name m["arguments"] = item.Arguments + 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 } return m } diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index d293780278..ebfe1b6782 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -248,11 +248,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,6 +321,9 @@ type ResponsesOutput struct { Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` + // type=custom_tool_call(custom/freeform 工具,input 为自由文本) + Input string `json:"input,omitempty"` + // type=web_search_call Action *WebSearchAction `json:"action,omitempty"` } @@ -402,6 +425,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_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index c499bec778..a609e8699f 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -53,6 +53,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( clientStream := responsesReq.Stream reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) serviceTier := extractOpenAIServiceTierFromBody(body) + // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 + // custom_tool_call 项,先记下名字集合。 + customTools := apicompat.CustomToolNames(responsesReq.Tools) chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) if err != nil { @@ -191,15 +194,16 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( c *gin.Context, resp *http.Response, originalModel string, + customTools map[string]bool, billingModel string, upstreamModel string, reasoningEffort *string, @@ -230,7 +234,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( }) return nil, fmt.Errorf("parse chat completions response: %w", err) } - responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel) + responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools) usage := OpenAIUsage{} if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { @@ -259,6 +263,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c *gin.Context, resp *http.Response, originalModel string, + customTools map[string]bool, billingModel string, upstreamModel string, reasoningEffort *string, @@ -283,6 +288,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( } state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) + state.CustomTools = customTools var usage OpenAIUsage var firstTokenMs *int clientDisconnected := false