From 75fb3c41c272163e02970d23df6c793f1519acf1 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Tue, 7 Jul 2026 16:23:44 +0800 Subject: [PATCH 001/104] =?UTF-8?q?fix(apicompat):=20responses=E2=86=92cha?= =?UTF-8?q?t=20=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 From 27e29f05621488b9402a373bbb434bda499645e7 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Tue, 7 Jul 2026 19:19:35 +0800 Subject: [PATCH 002/104] =?UTF-8?q?feat(apicompat):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20tool=5Fsearch=20=E6=94=AF=E6=8C=81=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatcompletions_responses_bridge.go | 114 +++++++++++++--- ...ions_responses_bridge_custom_tools_test.go | 127 +++++++++++++++++- .../chatcompletions_responses_test.go | 4 +- .../apicompat/responses_stream_event_wire.go | 23 ++++ .../responses_stream_event_wire_test.go | 20 +++ backend/internal/pkg/apicompat/types.go | 22 +++ .../openai_gateway_responses_chat_fallback.go | 13 +- 7 files changed, 299 insertions(+), 24 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 1567077cbb..88749207c9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -61,6 +61,19 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { return out } +// HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥 +// 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型 +// 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配 +// 触发 fatal 中止整个 turn。 +func HasToolSearchTool(tools []ResponsesTool) bool { + for _, tool := range tools { + if tool.Type == "tool_search" { + return true + } + } + return false +} + // responsesInputToChatMessages converts a Responses request's instructions + // input[] into Chat Completions messages. It is a three-stage pipeline: // @@ -661,8 +674,10 @@ func extractCustomToolCallInput(arguments string) string { // ChatCompletionsResponseToResponses converts a non-streaming Chat Completions // response into a Responses API response. customTools 是客户端请求中 custom 工具 -// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool) *ResponsesResponse { +// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项; +// toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 +// 的调用会还原为 tool_search_call 项。 +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -687,7 +702,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -702,7 +717,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool) []ResponsesOutput { +func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool) []ResponsesOutput { var outputs []ResponsesOutput if message.ReasoningContent != "" { outputs = append(outputs, ResponsesOutput{ @@ -748,6 +763,16 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo }) continue } + if toolSearch && toolCall.Function.Name == toolSearchProxyName { + outputs = append(outputs, ResponsesOutput{ + Type: "tool_search_call", + ID: generateItemID(), + CallID: toolCall.ID, + Arguments: arguments, + Status: "completed", + }) + continue + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), @@ -761,6 +786,21 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo return outputs } +// toolSearchCallArgumentsJSON 把降级 function 调用累积的 arguments 字符串还原为 +// tool_search_call 线上要求的 JSON 对象;模型未按 schema 输出(非法 JSON)时按 +// 字符串值兜底,交由 codex 解析报错后让模型重试。 +func toolSearchCallArgumentsJSON(arguments string) json.RawMessage { + trimmed := strings.TrimSpace(arguments) + if trimmed == "" { + return json.RawMessage(`{}`) + } + if json.Valid([]byte(trimmed)) { + return json.RawMessage(trimmed) + } + fallback, _ := json.Marshal(arguments) + return fallback +} + func emptyResponsesMessageOutput() ResponsesOutput { return ResponsesOutput{ Type: "message", @@ -857,10 +897,18 @@ type ChatCompletionsToResponsesStreamState struct { // 路由回它注册的 custom 工具。 CustomTools map[string]bool + // ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见 + // HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按 + // 该项类型(且 execution=client)执行 tool search。 + ToolSearchDeclared bool + // toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的 // 项类型一致。 toolIsCustom map[int]bool + // toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。 + toolIsToolSearch map[int]bool + // toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字 // 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。 toolAnnounced map[int]bool @@ -872,14 +920,15 @@ type ChatCompletionsToResponsesStreamState struct { // NewChatCompletionsToResponsesStreamState returns an initialized stream state. func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToResponsesStreamState { return &ChatCompletionsToResponsesStreamState{ - ResponseID: generateResponsesID(), - Model: model, - Created: time.Now().Unix(), - ToolCalls: make(map[int]*ChatToolCall), - ToolItemIDs: make(map[int]string), - ToolOutputIndex: make(map[int]int), - toolIsCustom: make(map[int]bool), - toolAnnounced: make(map[int]bool), + ResponseID: generateResponsesID(), + Model: model, + Created: time.Now().Unix(), + ToolCalls: make(map[int]*ChatToolCall), + ToolItemIDs: make(map[int]string), + ToolOutputIndex: make(map[int]int), + toolIsCustom: make(map[int]bool), + toolIsToolSearch: make(map[int]bool), + toolAnnounced: make(map[int]bool), } } @@ -978,8 +1027,9 @@ func ChatCompletionsChunkToResponsesEvents( stored.Function.Arguments += toolCall.Function.Arguments // 未宣告(名字未到)时仅累积,宣告时统一补发;custom 调用的 // arguments 是包裹 input 的 JSON 片段,无法增量还原为自由文本 - // 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems)。 - if state.toolAnnounced[idx] && !state.toolIsCustom[idx] { + // 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems); + // tool_search 调用同样收尾时随 output_item.done 全量下发。 + if state.toolAnnounced[idx] && !state.toolIsCustom[idx] && !state.toolIsToolSearch[idx] { events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{ OutputIndex: state.ToolOutputIndex[idx], ItemID: state.ToolItemIDs[idx], @@ -1210,16 +1260,21 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && len(state.CustomTools) > 0 { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared) { return nil } state.toolAnnounced[idx] = true isCustom := state.CustomTools[stored.Function.Name] + isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName state.toolIsCustom[idx] = isCustom + state.toolIsToolSearch[idx] = isToolSearch itemType := "function_call" if isCustom { itemType = "custom_tool_call" } + if isToolSearch { + itemType = "tool_search_call" + } events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ OutputIndex: state.ToolOutputIndex[idx], Item: &ResponsesOutput{ @@ -1230,8 +1285,8 @@ func announceChatToolItem( Status: "in_progress", }, })} - // 迟到宣告时补发已累积的参数增量(custom 工具的输入收尾统一下发,不补发)。 - if !isCustom && stored.Function.Arguments != "" { + // 迟到宣告时补发已累积的参数增量(custom/tool_search 的输入收尾统一下发,不补发)。 + if !isCustom && !isToolSearch && stored.Function.Arguments != "" { events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{ OutputIndex: state.ToolOutputIndex[idx], ItemID: state.ToolItemIDs[idx], @@ -1301,6 +1356,21 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response ) continue } + if state.toolIsToolSearch[i] { + // tool_search 调用按 tool_search_call 项收尾:codex 从 output_item.done + // 物化该调用(无参数增量事件),arguments 全量随项下发。 + events = append(events, chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ + OutputIndex: outputIndex, + Item: &ResponsesOutput{ + Type: "tool_search_call", + ID: itemID, + CallID: toolCall.ID, + Arguments: arguments, + Status: "completed", + }, + })) + continue + } events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ OutputIndex: outputIndex, @@ -1369,6 +1439,16 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp }) continue } + if state.toolIsToolSearch[i] { + outputs = append(outputs, ResponsesOutput{ + Type: "tool_search_call", + ID: generateItemID(), + CallID: toolCall.ID, + Arguments: arguments, + Status: "completed", + }) + continue + } outputs = append(outputs, ResponsesOutput{ Type: "function_call", ID: generateItemID(), 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 index f7828a2d00..a7428860be 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -103,7 +103,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false) require.Len(t, out.Output, 2) assert.Equal(t, "custom_tool_call", out.Output[0].Type) @@ -210,6 +210,131 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`) } +// codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行 +// tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配 +// 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。 +func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing.T) { + resp := &ChatCompletionsResponse{ + ID: "cc-1", + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail","limit":2}`}}, + }, + }, + }}, + } + + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true) + require.Len(t, out.Output, 1) + + item := out.Output[0] + assert.Equal(t, "tool_search_call", item.Type) + assert.Equal(t, "call_s", item.CallID) + + // 线上形态:execution 必须为 "client"(codex 的必填字段,非 client 被忽略), + // arguments 必须是 JSON 对象而非字符串(codex 按对象解析 query/limit)。 + b, err := json.Marshal(item) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.Equal(t, "client", m["execution"]) + args, ok := m["arguments"].(map[string]any) + require.True(t, ok, "arguments 必须序列化为 JSON 对象") + assert.Equal(t, "gmail", args["query"]) +} + +func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCall(t *testing.T) { + resp := &ChatCompletionsResponse{ + Choices: []ChatChoice{{ + Message: ChatMessage{ + Role: "assistant", + ToolCalls: []ChatToolCall{ + {ID: "call_s", Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`}}, + }, + }, + }}, + } + + // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false) + require.Len(t, out.Output, 1) + assert.Equal(t, "function_call", out.Output[0].Type) +} + +func TestChatCompletionsChunkToResponsesEvents_ToolSearchCallStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("glm-5.2") + state.ToolSearchDeclared = true + + idx := 0 + chunk := &ChatCompletionsChunk{ + ID: "cc-1", + Choices: []ChatChunkChoice{{ + Delta: ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + ID: "call_s", + Function: ChatFunctionCall{Name: "tool_search", Arguments: `{"query":"gmail"}`}, + }}, + }, + }}, + } + + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + var added, itemDone *ResponsesStreamEvent + for i := range events { + evt := &events[i] + switch evt.Type { + case "response.output_item.added": + if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" { + added = evt + } + case "response.output_item.done": + if evt.Item != nil && evt.Item.Type == "tool_search_call" { + itemDone = evt + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done", + "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + t.Fatalf("tool_search 调用不应产出 %s", evt.Type) + } + } + + require.NotNil(t, added, "缺少 tool_search_call 的 output_item.added") + assert.Equal(t, "tool_search_call", added.Item.Type) + + require.NotNil(t, itemDone, "缺少 tool_search_call 的 output_item.done") + assert.Equal(t, "call_s", itemDone.Item.CallID) + + // SSE 线上形态经 responsesItemWire 白名单重组,必须单独断言。 + sse, err := ResponsesEventToSSE(*itemDone) + require.NoError(t, err) + assert.Contains(t, sse, `"execution":"client"`) + assert.Contains(t, sse, `"arguments":{"query":"gmail"}`) + assert.Contains(t, sse, `"call_id":"call_s"`) + + // response.completed 的 output 数组同样携带 tool_search_call 项。 + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.NotNil(t, final.Response) + found := false + for _, item := range final.Response.Output { + if item.Type == "tool_search_call" { + found = true + assert.Equal(t, "call_s", item.CallID) + } + } + assert.True(t, found, "response.completed 缺少 tool_search_call 输出项") +} + +func TestHasToolSearchTool(t *testing.T) { + assert.True(t, HasToolSearchTool([]ResponsesTool{{Type: "tool_search"}})) + assert.False(t, HasToolSearchTool([]ResponsesTool{{Type: "function", Name: "tool_search"}})) + assert.False(t, HasToolSearchTool(nil)) +} + func TestResponsesToChatCompletionsRequest_NamespaceToolFlattensChildren(t *testing.T) { req := &ResponsesRequest{ Model: "glm-5.2", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index a703f0052b..fdb008b261 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", nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -396,7 +396,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/responses_stream_event_wire.go b/backend/internal/pkg/apicompat/responses_stream_event_wire.go index 6eaead4459..58b6831b10 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. @@ -173,6 +190,12 @@ func responsesItemWire(item *ResponsesOutput) map[string]any { m["call_id"] = item.CallID m["name"] = item.Name m["input"] = item.Input + case "tool_search_call": + // tool_search 调用还原项:execution 必须为 "client"(否则 codex 忽略该 + // 调用),arguments 在线上是 JSON 对象而非字符串。 + m["call_id"] = item.CallID + m["execution"] = "client" + m["arguments"] = toolSearchCallArgumentsJSON(item.Arguments) } return m } 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 ebfe1b6782..25baa7a080 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -328,6 +328,28 @@ type ResponsesOutput struct { Action *WebSearchAction `json:"action,omitempty"` } +// MarshalJSON 处理 tool_search_call 项的线上形态(复用 CallID/Arguments 字段): +// execution 固定为 "client"(codex 的必填字段,非 client 的调用会被静默忽略), +// arguments 是 JSON 对象而非 function_call 语义下的字符串。其余类型走默认结构体 +// 序列化,输出逐字节不变。 +func (o ResponsesOutput) MarshalJSON() ([]byte, error) { + type responsesOutputAlias ResponsesOutput + if o.Type != "tool_search_call" { + return json.Marshal(responsesOutputAlias(o)) + } + m := map[string]any{ + "type": o.Type, + "id": o.ID, + "call_id": o.CallID, + "execution": "client", + "arguments": toolSearchCallArgumentsJSON(o.Arguments), + } + if o.Status != "" { + m["status"] = o.Status + } + return json.Marshal(m) +} + // WebSearchAction describes the search action in a web_search_call output item. type WebSearchAction struct { Type string `json:"type,omitempty"` // "search" diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index a609e8699f..1c75c746d6 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -54,8 +54,10 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel) serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 - // custom_tool_call 项,先记下名字集合。 + // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 + // tool_search_call 项。 customTools := apicompat.CustomToolNames(responsesReq.Tools) + toolSearch := apicompat.HasToolSearchTool(responsesReq.Tools) chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) if err != nil { @@ -194,9 +196,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -204,6 +206,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + toolSearch bool, billingModel string, upstreamModel string, reasoningEffort *string, @@ -234,7 +237,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( }) return nil, fmt.Errorf("parse chat completions response: %w", err) } - responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools) + responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools, toolSearch) usage := OpenAIUsage{} if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { @@ -264,6 +267,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + toolSearch bool, billingModel string, upstreamModel string, reasoningEffort *string, @@ -289,6 +293,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools + state.ToolSearchDeclared = toolSearch var usage OpenAIUsage var firstTokenMs *int clientDisconnected := false From 79423383287e945a1d953a6f280bf35ea6b7f422 Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 17:38:17 +0800 Subject: [PATCH 003/104] =?UTF-8?q?fix(apicompat):=20=E5=9B=9E=E7=A8=8B?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=20namespace=20=E5=AD=90=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Codex=20MCP=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20unsupported=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 0.14x 将 MCP 工具声明为 namespace 工具,chat 桥去程摊平为 "__" function 工具后,回程仅原样回传平铺名的 function_call 项;codex 按 namespace+name 路由查不到该名字,所有 MCP 工具调用被判为 unsupported call。 - NamespaceToolNames 构建摊平名 →(namespace, 子工具名)反查表 (摊平名超长带截断哈希,无法按字符串切分还原) - 非流式/流式回程命中映射时还原为裸子工具名 + namespace 字段, ResponsesOutput 新增 Namespace 字段并同步 wire 层白名单 - 回退桥入口将映射与 CustomTools/ToolSearchDeclared 一并穿入 已在测试机经 codex exec + MCP server 端到端验证。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 103 ++++++++-- ...ions_responses_bridge_custom_tools_test.go | 190 +++++++++++++++++- .../chatcompletions_responses_test.go | 4 +- .../apicompat/responses_stream_event_wire.go | 5 + backend/internal/pkg/apicompat/types.go | 2 + .../openai_gateway_messages_chat_fallback.go | 2 +- .../openai_gateway_responses_chat_fallback.go | 13 +- 7 files changed, 296 insertions(+), 23 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 52c93be761..6262ed3dd2 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -64,6 +64,42 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { 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),无法按字符串切分还原。 +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 不匹配 @@ -679,8 +715,9 @@ func extractCustomToolCallInput(arguments string) string { // response into a Responses API response. customTools 是客户端请求中 custom 工具 // 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项; // toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 -// 的调用会还原为 tool_search_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool) *ResponsesResponse { +// 的调用会还原为 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 @@ -705,7 +742,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -720,7 +757,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool) []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{ @@ -776,6 +813,18 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo }) 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(), @@ -905,6 +954,11 @@ type ChatCompletionsToResponsesStreamState struct { // 该项类型(且 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 @@ -912,6 +966,9 @@ type ChatCompletionsToResponsesStreamState struct { // toolIsToolSearch 记录工具调用是否判定为 tool_search 代理调用。 toolIsToolSearch map[int]bool + // toolNamespace 记录工具调用宣告时命中的 namespace 归属(见 NamespaceTools)。 + toolNamespace map[int]NamespacedToolName + // toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字 // 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。 toolAnnounced map[int]bool @@ -931,6 +988,7 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe 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), } } @@ -1263,7 +1321,7 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared) { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { return nil } state.toolAnnounced[idx] = true @@ -1278,14 +1336,22 @@ func announceChatToolItem( 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: stored.Function.Name, - Status: "in_progress", + Type: itemType, + ID: state.ToolItemIDs[idx], + CallID: stored.ID, + Name: itemName, + Namespace: itemNamespace, + Status: "in_progress", }, })} // 迟到宣告时补发已累积的参数增量(custom/tool_search 的输入收尾统一下发,不补发)。 @@ -1374,12 +1440,17 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response })) 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{ @@ -1388,7 +1459,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", }, @@ -1452,11 +1524,16 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp }) 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 index a7428860be..37702ed699 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -103,7 +103,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false) + 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) @@ -226,7 +226,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil) require.Len(t, out.Output, 1) item := out.Output[0] @@ -258,7 +258,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa } // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil) require.Len(t, out.Output, 1) assert.Equal(t, "function_call", out.Output[0].Type) } @@ -506,6 +506,190 @@ func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) { 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)) +} + +// 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} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 66afcf2270..775a04eaa0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -418,7 +418,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -452,7 +452,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false) + 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 58b6831b10..c2ebcd3eee 100644 --- a/backend/internal/pkg/apicompat/responses_stream_event_wire.go +++ b/backend/internal/pkg/apicompat/responses_stream_event_wire.go @@ -184,6 +184,11 @@ 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)。 diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 41969bc024..0eda9c329d 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -321,6 +321,8 @@ 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"` diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 9877e66af7..fed065ba0a 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, nil, false) + 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 e722f86969..0a29996fa8 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -42,9 +42,11 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 - // tool_search_call 项。 + // 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 { @@ -105,9 +107,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, 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, customTools, toolSearch, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -116,6 +118,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( originalModel string, customTools map[string]bool, toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -127,7 +130,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -153,6 +156,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( originalModel string, customTools map[string]bool, toolSearch bool, + namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, upstreamModel string, reasoningEffort *string, @@ -165,6 +169,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools state.ToolSearchDeclared = toolSearch + state.NamespaceTools = namespaceTools clientDisconnected := false writeEvents := func(events []apicompat.ResponsesStreamEvent) { From f1082bb78f788e716c810103101b10b854d2f77d Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 17:38:48 +0800 Subject: [PATCH 004/104] =?UTF-8?q?fix(apicompat):=20namespace=20=E6=91=8A?= =?UTF-8?q?=E5=B9=B3=E5=90=8D=E6=92=9E=E5=90=8D=E6=97=B6=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摊平名与顶层 function/custom 工具撞名、或跨 namespace 摊平出同名时, chat 上游无法按 namespace 区分调用归属:此前重复声明照发上游、回程 固定命中其中一条映射,调用可能被还原到错误工具。这类请求在原生 Responses 上游合法,歧义由摊平转换制造且不可消除,改为在请求转换 阶段直接报错(网关返回 400 invalid_request_error 并点名冲突双方); 同一 (namespace, 子工具) 的重复声明去重后不拒绝。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 53 +++++++++++++++---- ...ions_responses_bridge_custom_tools_test.go | 46 ++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 6262ed3dd2..20391254fc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -35,7 +35,11 @@ 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 } // tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发 // tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。 @@ -74,6 +78,8 @@ type NamespacedToolName struct { // 子工具名)映射。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 { @@ -554,7 +560,18 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra // 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 { +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) out := make([]ChatTool, 0, len(tools)) for _, tool := range tools { switch tool.Type { @@ -582,12 +599,16 @@ func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool { case "tool_search": out = append(out, toolSearchProxyChatTool()) case "namespace": - out = append(out, namespaceChildrenToChatTools(tool)...) + flattened, err := namespaceChildrenToChatTools(tool, topLevel, flatOwner) + if err != nil { + return nil, err + } + out = append(out, flattened...) } // 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有 // 对应能力,维持丢弃。 } - return out + return out, nil } // toolSearchProxyName 是 tool_search 服务端工具降级后的 function 工具名。模型对 @@ -608,10 +629,12 @@ func toolSearchProxyChatTool() ChatTool { } // namespaceChildrenToChatTools 将 namespace 工具的子 function 工具摊平为顶层 -// function 工具,名字加 "__" 前缀。 -func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool { +// function 工具,名字加 "__" 前缀。摊平名与顶层工具或其他 namespace +// 撞名时返回错误(歧义不可消除,显式拒绝);同一 (namespace, 子工具) 的重复声明 +// 去重后不算冲突。 +func namespaceChildrenToChatTools(tool ResponsesTool, topLevel map[string]bool, flatOwner map[string]NamespacedToolName) ([]ChatTool, error) { if tool.Name == "" { - return nil + return nil, nil } children := tool.Tools if len(children) == 0 { @@ -622,17 +645,29 @@ func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool { 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: flattenNamespaceToolName(tool.Name, child.Name), + Name: flat, Description: child.Description, Parameters: child.Parameters, Strict: child.Strict, }, }) } - return out + return out, nil } // chatToolNameMaxLen 是 Chat Completions function 工具名的通用长度上限。 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 index 37702ed699..778ce08a62 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -536,6 +536,52 @@ func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) { assert.Nil(t, NamespaceToolNames(nil)) } +// 客户端请求在原生 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 拒绝执行。 From 0d28f7f90d80bfdbf9d44e3efe2ddbfc5a58f7e0 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 10 Jul 2026 20:40:45 +0800 Subject: [PATCH 005/104] =?UTF-8?q?fix(apicompat):=20Responses=E2=86=94Ant?= =?UTF-8?q?hropic=20=E8=BD=AC=E6=8D=A2=E8=A1=A5=E9=BD=90=20cache=5Fcreatio?= =?UTF-8?q?n=5Finput=5Ftokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3935 --- .../anthropic_to_responses_response.go | 14 ++-- ...responses_anthropic_cache_creation_test.go | 64 +++++++++++++++++++ .../pkg/apicompat/responses_to_anthropic.go | 9 +-- 3 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index de8ab78df8..67c161bdd3 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -102,9 +102,10 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens out.Usage = &ResponsesUsage{ - InputTokens: totalInputTokens, - OutputTokens: resp.Usage.OutputTokens, - TotalTokens: totalInputTokens + resp.Usage.OutputTokens, + InputTokens: totalInputTokens, + OutputTokens: resp.Usage.OutputTokens, + TotalTokens: totalInputTokens + resp.Usage.OutputTokens, + CacheCreationInputTokens: resp.Usage.CacheCreationInputTokens, } if resp.Usage.CacheReadInputTokens > 0 { out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{ @@ -497,9 +498,10 @@ func makeResponsesCompletedEvent( // back to match OpenAI Responses semantics where input_tokens is the total. totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens usage := &ResponsesUsage{ - InputTokens: totalInputTokens, - OutputTokens: state.OutputTokens, - TotalTokens: totalInputTokens + state.OutputTokens, + InputTokens: totalInputTokens, + OutputTokens: state.OutputTokens, + TotalTokens: totalInputTokens + state.OutputTokens, + CacheCreationInputTokens: state.CacheCreationInputTokens, } if state.CacheReadInputTokens > 0 { usage.InputTokensDetails = &ResponsesInputTokensDetails{ diff --git a/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go new file mode 100644 index 0000000000..a5856845c9 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go @@ -0,0 +1,64 @@ +package apicompat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnthropicUsageFromResponsesUsage_CacheCreation(t *testing.T) { + usage := &ResponsesUsage{ + InputTokens: 20, + OutputTokens: 5, + CacheCreationInputTokens: 6, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 4, + }, + } + + got := anthropicUsageFromResponsesUsage(usage) + + assert.Equal(t, 10, got.InputTokens, "input = total(20) - cache_read(4) - cache_creation(6)") + assert.Equal(t, 5, got.OutputTokens) + assert.Equal(t, 4, got.CacheReadInputTokens) + assert.Equal(t, 6, got.CacheCreationInputTokens, "cache creation must be preserved") +} + +func TestAnthropicUsageFromResponsesUsage_NoCacheCreation(t *testing.T) { + usage := &ResponsesUsage{ + InputTokens: 10, + OutputTokens: 5, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 3, + }, + } + + got := anthropicUsageFromResponsesUsage(usage) + + assert.Equal(t, 7, got.InputTokens) + assert.Equal(t, 3, got.CacheReadInputTokens) + assert.Equal(t, 0, got.CacheCreationInputTokens) +} + +func TestAnthropicToResponsesResponse_CacheCreation(t *testing.T) { + resp := AnthropicResponse{ + ID: "msg_test", + Type: "message", + Role: "assistant", + Model: "claude-opus-4-6", + Usage: AnthropicUsage{ + InputTokens: 10, + OutputTokens: 5, + CacheReadInputTokens: 4, + CacheCreationInputTokens: 6, + }, + StopReason: "end_turn", + } + + out := AnthropicToResponsesResponse(&resp) + + require.NotNil(t, out.Usage) + assert.Equal(t, 20, out.Usage.InputTokens, "total = input(10) + cache_read(4) + cache_creation(6)") + assert.Equal(t, 6, out.Usage.CacheCreationInputTokens, "cache creation must round-trip") +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 037e16b652..9cc6e49c75 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -100,15 +100,16 @@ func anthropicUsageFromResponsesUsage(usage *ResponsesUsage) AnthropicUsage { cachedTokens = usage.InputTokensDetails.CachedTokens } - inputTokens := usage.InputTokens - cachedTokens + inputTokens := usage.InputTokens - cachedTokens - usage.CacheCreationInputTokens if inputTokens < 0 { inputTokens = 0 } return AnthropicUsage{ - InputTokens: inputTokens, - OutputTokens: usage.OutputTokens, - CacheReadInputTokens: cachedTokens, + InputTokens: inputTokens, + OutputTokens: usage.OutputTokens, + CacheReadInputTokens: cachedTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, } } From 83f169e4fa815f7083de23e301d1a1560dc71ca8 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 10 Jul 2026 20:49:44 +0800 Subject: [PATCH 006/104] =?UTF-8?q?fix(apicompat):=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?Responses=E2=86=92Anthropic=20=E6=B5=81=E5=BC=8F=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=9A=84=20cache=5Fcreation=5Finput=5Ftokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...responses_anthropic_cache_creation_test.go | 35 +++++++++++++++++++ .../pkg/apicompat/responses_to_anthropic.go | 23 +++++++----- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go index a5856845c9..b8c7916d2d 100644 --- a/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go +++ b/backend/internal/pkg/apicompat/responses_anthropic_cache_creation_test.go @@ -41,6 +41,41 @@ func TestAnthropicUsageFromResponsesUsage_NoCacheCreation(t *testing.T) { assert.Equal(t, 0, got.CacheCreationInputTokens) } +func TestResponsesEventToAnthropicEvents_StreamingCacheCreation(t *testing.T) { + state := NewResponsesEventToAnthropicState() + state.MessageStartSent = true + + completedEvt := &ResponsesStreamEvent{ + Type: "response.completed", + Response: &ResponsesResponse{ + Status: "completed", + Usage: &ResponsesUsage{ + InputTokens: 20, + OutputTokens: 5, + CacheCreationInputTokens: 6, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 4, + }, + }, + }, + } + + events := ResponsesEventToAnthropicEvents(completedEvt, state) + + var deltaEvt *AnthropicStreamEvent + for i := range events { + if events[i].Type == "message_delta" { + deltaEvt = &events[i] + break + } + } + require.NotNil(t, deltaEvt, "should have message_delta event") + require.NotNil(t, deltaEvt.Usage) + assert.Equal(t, 6, deltaEvt.Usage.CacheCreationInputTokens, "streaming cache_creation must be preserved") + assert.Equal(t, 10, deltaEvt.Usage.InputTokens, "input = 20 - 4(read) - 6(creation)") + assert.Equal(t, 4, deltaEvt.Usage.CacheReadInputTokens) +} + func TestAnthropicToResponsesResponse_CacheCreation(t *testing.T) { resp := AnthropicResponse{ ID: "msg_test", diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 9cc6e49c75..9c3b85b2ef 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -182,9 +182,10 @@ type ResponsesEventToAnthropicState struct { // OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index. OutputIndexToBlockIdx map[int]int - InputTokens int - OutputTokens int - CacheReadInputTokens int + InputTokens int + OutputTokens int + CacheReadInputTokens int + CacheCreationInputTokens int ResponseID string Model string @@ -259,9 +260,10 @@ func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []A StopReason: stopReason, }, Usage: &AnthropicUsage{ - InputTokens: state.InputTokens, - OutputTokens: state.OutputTokens, - CacheReadInputTokens: state.CacheReadInputTokens, + InputTokens: state.InputTokens, + OutputTokens: state.OutputTokens, + CacheReadInputTokens: state.CacheReadInputTokens, + CacheCreationInputTokens: state.CacheCreationInputTokens, }, }, AnthropicStreamEvent{Type: "message_stop"}, @@ -579,6 +581,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo state.InputTokens = usage.InputTokens state.OutputTokens = usage.OutputTokens state.CacheReadInputTokens = usage.CacheReadInputTokens + state.CacheCreationInputTokens = usage.CacheCreationInputTokens } if evt.Response != nil { if evt.Response.Usage != nil { @@ -586,6 +589,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo state.InputTokens = usage.InputTokens state.OutputTokens = usage.OutputTokens state.CacheReadInputTokens = usage.CacheReadInputTokens + state.CacheCreationInputTokens = usage.CacheCreationInputTokens } switch evt.Response.Status { case "incomplete": @@ -606,9 +610,10 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo StopReason: stopReason, }, Usage: &AnthropicUsage{ - InputTokens: state.InputTokens, - OutputTokens: state.OutputTokens, - CacheReadInputTokens: state.CacheReadInputTokens, + InputTokens: state.InputTokens, + OutputTokens: state.OutputTokens, + CacheReadInputTokens: state.CacheReadInputTokens, + CacheCreationInputTokens: state.CacheCreationInputTokens, }, }, AnthropicStreamEvent{Type: "message_stop"}, From 89a551b964076f2e61b71c0b8fa34f9464100cb0 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 10 Jul 2026 20:56:31 +0800 Subject: [PATCH 007/104] =?UTF-8?q?fix(handler):=20opsCaptureWriter=20?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E5=90=8E=E8=AE=BF=E9=97=AE=20nil=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3961 --- .../handler/ops_capture_writer_nil_test.go | 36 +++++++++++++++++++ backend/internal/handler/ops_error_logger.go | 27 ++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 backend/internal/handler/ops_capture_writer_nil_test.go diff --git a/backend/internal/handler/ops_capture_writer_nil_test.go b/backend/internal/handler/ops_capture_writer_nil_test.go new file mode 100644 index 0000000000..76f50b8e45 --- /dev/null +++ b/backend/internal/handler/ops_capture_writer_nil_test.go @@ -0,0 +1,36 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOpsCaptureWriter_NilInnerWriter_NoPanic(t *testing.T) { + w := &opsCaptureWriter{} + w.ResponseWriter = nil + + assert.NotPanics(t, func() { + assert.Equal(t, 0, w.Status()) + }, "Status() on released writer must not panic") + + assert.NotPanics(t, func() { + assert.Equal(t, -1, w.Size()) + }, "Size() on released writer must not panic") + + assert.NotPanics(t, func() { + assert.False(t, w.Written()) + }, "Written() on released writer must not panic") + + assert.NotPanics(t, func() { + n, err := w.Write([]byte("test")) + assert.Equal(t, 0, n) + assert.NoError(t, err) + }, "Write() on released writer must not panic") + + assert.NotPanics(t, func() { + n, err := w.WriteString("test") + assert.Equal(t, 0, n) + assert.NoError(t, err) + }, "WriteString() on released writer must not panic") +} diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 5a1e57ff7d..16aee7fe5a 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -498,7 +498,31 @@ func releaseOpsCaptureWriter(w *opsCaptureWriter) { opsCaptureWriterPool.Put(w) } +func (w *opsCaptureWriter) Status() int { + if w.ResponseWriter == nil { + return 0 + } + return w.ResponseWriter.Status() +} + +func (w *opsCaptureWriter) Size() int { + if w.ResponseWriter == nil { + return -1 + } + return w.ResponseWriter.Size() +} + +func (w *opsCaptureWriter) Written() bool { + if w.ResponseWriter == nil { + return false + } + return w.ResponseWriter.Written() +} + func (w *opsCaptureWriter) Write(b []byte) (int, error) { + if w.ResponseWriter == nil { + return 0, nil + } if w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit { remaining := w.limit - w.buf.Len() if len(b) > remaining { @@ -511,6 +535,9 @@ func (w *opsCaptureWriter) Write(b []byte) (int, error) { } func (w *opsCaptureWriter) WriteString(s string) (int, error) { + if w.ResponseWriter == nil { + return 0, nil + } if w.Status() >= 400 && w.limit > 0 && w.buf.Len() < w.limit { remaining := w.limit - w.buf.Len() if len(s) > remaining { From bc3cb290276922074213c5bc8ebc404bc6d083a8 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 10 Jul 2026 21:03:40 +0800 Subject: [PATCH 008/104] =?UTF-8?q?fix(handler):=20=E8=A1=A5=E9=BD=90=20op?= =?UTF-8?q?sCaptureWriter=20=E5=85=A8=E9=83=A8=E5=A7=94=E6=89=98=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E7=9A=84=20nil=20=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/ops_capture_writer_nil_test.go | 41 ++++++++++---- backend/internal/handler/ops_error_logger.go | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/ops_capture_writer_nil_test.go b/backend/internal/handler/ops_capture_writer_nil_test.go index 76f50b8e45..4e96333f9d 100644 --- a/backend/internal/handler/ops_capture_writer_nil_test.go +++ b/backend/internal/handler/ops_capture_writer_nil_test.go @@ -12,25 +12,48 @@ func TestOpsCaptureWriter_NilInnerWriter_NoPanic(t *testing.T) { assert.NotPanics(t, func() { assert.Equal(t, 0, w.Status()) - }, "Status() on released writer must not panic") - + }) assert.NotPanics(t, func() { assert.Equal(t, -1, w.Size()) - }, "Size() on released writer must not panic") - + }) assert.NotPanics(t, func() { assert.False(t, w.Written()) - }, "Written() on released writer must not panic") - + }) assert.NotPanics(t, func() { n, err := w.Write([]byte("test")) assert.Equal(t, 0, n) assert.NoError(t, err) - }, "Write() on released writer must not panic") - + }) assert.NotPanics(t, func() { n, err := w.WriteString("test") assert.Equal(t, 0, n) assert.NoError(t, err) - }, "WriteString() on released writer must not panic") + }) + assert.NotPanics(t, func() { + h := w.Header() + assert.NotNil(t, h) + }) + assert.NotPanics(t, func() { + w.WriteHeader(200) + }) + assert.NotPanics(t, func() { + w.WriteHeaderNow() + }) + assert.NotPanics(t, func() { + w.Flush() + }) + assert.NotPanics(t, func() { + conn, rw, err := w.Hijack() + assert.Nil(t, conn) + assert.Nil(t, rw) + assert.Error(t, err) + }) + assert.NotPanics(t, func() { + ch := w.CloseNotify() + assert.NotNil(t, ch) + }) + assert.NotPanics(t, func() { + p := w.Pusher() + assert.Nil(t, p) + }) } diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 16aee7fe5a..64aa3ba495 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1,11 +1,14 @@ package handler import ( + "bufio" "bytes" "context" "encoding/json" "errors" "log" + "net" + "net/http" "runtime" "runtime/debug" "strconv" @@ -519,6 +522,57 @@ func (w *opsCaptureWriter) Written() bool { return w.ResponseWriter.Written() } +func (w *opsCaptureWriter) Header() http.Header { + if w.ResponseWriter == nil { + return http.Header{} + } + return w.ResponseWriter.Header() +} + +func (w *opsCaptureWriter) WriteHeader(code int) { + if w.ResponseWriter == nil { + return + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *opsCaptureWriter) WriteHeaderNow() { + if w.ResponseWriter == nil { + return + } + w.ResponseWriter.WriteHeaderNow() +} + +func (w *opsCaptureWriter) Flush() { + if w.ResponseWriter == nil { + return + } + w.ResponseWriter.Flush() +} + +func (w *opsCaptureWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if w.ResponseWriter == nil { + return nil, nil, errors.New("response writer released") + } + return w.ResponseWriter.Hijack() +} + +func (w *opsCaptureWriter) CloseNotify() <-chan bool { + if w.ResponseWriter == nil { + ch := make(chan bool) + close(ch) + return ch + } + return w.ResponseWriter.CloseNotify() +} + +func (w *opsCaptureWriter) Pusher() http.Pusher { + if w.ResponseWriter == nil { + return nil + } + return w.ResponseWriter.Pusher() +} + func (w *opsCaptureWriter) Write(b []byte) (int, error) { if w.ResponseWriter == nil { return 0, nil From a2cdaa6419e0ab2cb20b38ed64981c6ffd57046a Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 21:06:26 +0800 Subject: [PATCH 009/104] =?UTF-8?q?fix(apicompat):=20=E5=86=85=E7=BD=AE=20?= =?UTF-8?q?tool=5Fsearch=20=E4=B8=8E=E5=90=8C=E5=90=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=92=9E=E5=90=8D=E6=97=B6=E6=98=BE=E5=BC=8F=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_search 服务端工具降级后的代理 function 不能改名(codex 的模型侧按 tool_search 这个名字调用),与客户端声明的同名 function/custom 工具无法 区分:去程产生重复声明,回程会把普通工具的调用劫持还原成 tool_search_call。 与 namespace 摊平撞名的处理一致,在请求转换阶段显式报错(网关 400); 重复声明 type=tool_search 去重后只产出一个代理,不拒绝。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 11 ++++++ ...ions_responses_bridge_custom_tools_test.go | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 1357404fd8..c69dba6df4 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -573,6 +573,7 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { } } flatOwner := make(map[string]NamespacedToolName) + toolSearchDeclared := false out := make([]ChatTool, 0, len(tools)) for _, tool := range tools { switch tool.Type { @@ -598,6 +599,16 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { }, }) 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) 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 index 778ce08a62..e6893429f8 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -536,6 +536,44 @@ func TestNamespaceToolNames_MapsFlattenedNames(t *testing.T) { 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) +} + // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。 From e2b68d1f905005f394117643f4e1fed512d1ad3e Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 21:06:45 +0800 Subject: [PATCH 010/104] =?UTF-8?q?fix(apicompat):=20tool=5Fchoice=20?= =?UTF-8?q?=E4=BB=85=E8=BD=AC=E5=8F=91=E5=AE=9E=E9=99=85=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E4=BA=8E=E8=BD=AC=E6=8D=A2=E7=BB=93=E6=9E=9C=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前只检查转换后是否还剩任意工具,不校验 tool_choice 指向的工具是否幸存: 强制选择被丢弃的服务端工具(如 web_search)或指向不存在名字时,选择项被 原样转发,chat 上游因 tool_choice 指向未声明工具而 400。改为具名选择项仅 在目标存在于转换后工具集时转发,服务端工具类选择项随工具本身丢弃; "auto" 等字符串形式保持原样转发。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 23 +++++++-- ...ions_responses_bridge_custom_tools_test.go | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index c69dba6df4..5751163cd7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -44,8 +44,17 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR } // tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发 // tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。 + // 指向被丢弃工具的选择项同理(见 responsesToolChoiceToChatToolChoice)。 if len(out.Tools) > 0 && len(req.ToolChoice) > 0 { - out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice) + declared := make(map[string]bool, len(out.Tools)) + for _, tool := range out.Tools { + if tool.Function != nil { + declared[tool.Function.Name] = true + } + } + if tc := responsesToolChoiceToChatToolChoice(req.ToolChoice, declared); len(tc) > 0 { + out.ToolChoice = tc + } } if req.Text != nil { out.ResponseFormat = responsesTextFormatToChatResponseFormat(req.Text.Format) @@ -705,14 +714,19 @@ func flattenNamespaceToolName(namespace, name string) string { return prefix.String() + suffix } -func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { +// responsesToolChoiceToChatToolChoice 把 Responses 的 tool_choice 转为 chat 形态。 +// declared 是转换后实际声明的 chat 工具名集合:具名选择项仅在目标工具幸存时转发, +// 服务端工具(web_search 等)的选择项随工具本身丢弃——指向未声明工具的 tool_choice +// 会被 chat 上游 400 拒绝。返回 nil 表示丢弃 tool_choice。 +func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[string]bool) json.RawMessage { var choice map[string]json.RawMessage if err := json.Unmarshal(raw, &choice); err != nil { + // "auto"/"none"/"required" 等字符串形式原样转发。 return raw } // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 if t := rawString(choice["type"]); t != "function" && t != "custom" { - return raw + return nil } name := rawString(choice["name"]) if name == "" { @@ -721,6 +735,9 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage { if name == "" { return raw } + if !declared[name] { + return nil + } out, err := json.Marshal(map[string]any{ "type": "function", "function": map[string]string{ 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 index e6893429f8..5f16839eda 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -574,6 +574,53 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test assert.Equal(t, "tool_search", out.Tools[0].Function.Name) } +// tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发, +// chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。 +func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) { + // 强制选择被丢弃的 web_search:工具没了,选择项也必须丢。 + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + {Type: "web_search"}, + }, + ToolChoice: json.RawMessage(`{"type":"web_search"}`), + }) + require.NoError(t, err) + require.Len(t, out.Tools, 1) + assert.Empty(t, out.ToolChoice, "指向被丢弃服务端工具的 tool_choice 必须丢弃") + + // 具名选择指向不存在的工具名。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"function","name":"missing"}`), + }) + require.NoError(t, err) + assert.Empty(t, out.ToolChoice, "指向不存在工具名的 tool_choice 必须丢弃") + + // 字符串形式与指向幸存工具的选择保持原有转发行为。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`"auto"`), + }) + require.NoError(t, err) + assert.JSONEq(t, `"auto"`, string(out.ToolChoice)) + + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"function","name":"wait"}`), + }) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice)) +} + // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。 From 90e9d03dec4dafc4e9bb354c7c48c1b4cc02c4ef Mon Sep 17 00:00:00 2001 From: xlplbo Date: Fri, 10 Jul 2026 21:14:33 +0800 Subject: [PATCH 011/104] =?UTF-8?q?fix(apicompat):=20=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=80=89=E6=8B=A9=20tool=5Fsearch=20=E7=9A=84=20tool=5Fchoice?= =?UTF-8?q?=20=E9=99=8D=E7=BA=A7=E4=B8=BA=E6=8C=87=E5=90=91=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E7=9A=84=20function=20=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_search 工具未被丢弃而是降级为同名 function 代理,此前 tool_choice 过滤 把它连同其他服务端工具类型一起静默丢弃,强制工具搜索被退化为自动选择, 违反调用方明确指定的工具选择语义。改为映射到指向代理的 function 选择; 未声明 type=tool_search 时无可指向的代理,维持丢弃。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWQyEgFKKbdve67G6qCoAU --- .../chatcompletions_responses_bridge.go | 26 ++++++++++++------- ...ions_responses_bridge_custom_tools_test.go | 24 +++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 5751163cd7..23178bf3a0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -724,17 +724,25 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage, declared map[strin // "auto"/"none"/"required" 等字符串形式原样转发。 return raw } - // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 - if t := rawString(choice["type"]); t != "function" && t != "custom" { + var name string + switch rawString(choice["type"]) { + case "tool_search": + // tool_search 未被丢弃而是降级为同名 function 代理(见 + // responsesToolsToChatTools),强制选择它同样降级为 function 选择, + // 静默丢弃会把强制搜索退化为自动选择。 + name = toolSearchProxyName + case "function", "custom": + // custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。 + name = rawString(choice["name"]) + if name == "" { + name = rawNestedString(choice["function"], "name") + } + if name == "" { + return raw + } + default: return nil } - name := rawString(choice["name"]) - if name == "" { - name = rawNestedString(choice["function"], "name") - } - if name == "" { - return raw - } if !declared[name] { return nil } 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 index 5f16839eda..9271b50d1d 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -621,6 +621,30 @@ func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *test assert.JSONEq(t, `{"type":"function","function":{"name":"wait"}}`, string(out.ToolChoice)) } +// tool_search 工具没有被丢弃而是降级为同名 function 代理,强制选择它的 tool_choice +// 必须同步降级为指向代理的 function 选择,不能静默丢弃(丢弃会把强制搜索退化为 +// 自动选择,模型可以不执行搜索)。 +func TestResponsesToChatCompletionsRequest_ToolSearchToolChoiceMapsToProxy(t *testing.T) { + out, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "tool_search"}}, + ToolChoice: json.RawMessage(`{"type":"tool_search"}`), + }) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"function","function":{"name":"tool_search"}}`, string(out.ToolChoice)) + + // 未声明 type=tool_search 时强制选择它没有可指向的代理,丢弃选择项。 + out, err = ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: []ResponsesTool{{Type: "function", Name: "wait"}}, + ToolChoice: json.RawMessage(`{"type":"tool_search"}`), + }) + require.NoError(t, err) + assert.Empty(t, out.ToolChoice) +} + // 客户端请求在原生 Responses API 上合法(namespace 子工具按 namespace+name 路由), // 是摊平转换让名字产生歧义;歧义无法消除时必须显式拒绝整个请求(400),而不是 // 静默降级——否则重复声明发给上游、回程还原到错误工具,问题只能靠抓包定位。 From 6c588bb950dafa6db2b4413e896d93b4cb592944 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:24:13 +0000 Subject: [PATCH 012/104] chore: sync VERSION to 0.1.151 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 23c64fda6f..6c015fd0ca 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.150 +0.1.151 From 0464856c4aa5deb613dabff662f6ca6bf98fba13 Mon Sep 17 00:00:00 2001 From: Lyonle <214648221+lyon-le@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:27:15 +0800 Subject: [PATCH 013/104] =?UTF-8?q?feat(frontend):=20Fast/Flex=20=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将手工 User ID 输入替换为带防抖的邮箱模糊搜索和多选标签,同时继续保存 user_ids。 回显已有用户邮箱并保留无法解析的历史 ID,补齐中英文文案路径及组件回归测试。 --- .../__tests__/openaiFastPolicyLocales.spec.ts | 30 +++ .../src/i18n/locales/en/admin/settings.ts | 12 +- .../src/i18n/locales/zh/admin/settings.ts | 12 +- frontend/src/views/admin/SettingsView.vue | 71 +----- .../settings/OpenAIFastPolicyUserSelector.vue | 229 ++++++++++++++++++ .../OpenAIFastPolicyUserSelector.spec.ts | 112 +++++++++ 6 files changed, 390 insertions(+), 76 deletions(-) create mode 100644 frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts create mode 100644 frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue create mode 100644 frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts diff --git a/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts new file mode 100644 index 0000000000..7fd550c1ad --- /dev/null +++ b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import en from '../locales/en' +import zh from '../locales/zh' + +describe('OpenAI Fast/Flex policy locale keys', () => { + it('exposes user scope copy at the runtime zh path', () => { + expect(zh.admin.settings.openaiFastPolicy).toMatchObject({ + userIds: '指定用户', + userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。', + userSearchPlaceholder: '输入用户邮箱搜索', + userSearchEmpty: '未找到匹配用户', + userDeleted: '(已删除)', + userIdFallback: '用户 #{id}', + removeUser: '移除用户' + }) + }) + + it('exposes user scope copy at the runtime en path', () => { + expect(en.admin.settings.openaiFastPolicy).toMatchObject({ + userIds: 'Specific users', + userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.', + userSearchPlaceholder: 'Search by user email', + userSearchEmpty: 'No matching users found', + userDeleted: '(deleted)', + userIdFallback: 'User #{id}', + removeUser: 'Remove user' + }) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 37dc8aa1c8..e08be9f591 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -979,11 +979,6 @@ export default { scopeOAuth: 'OAuth only', scopeAPIKey: 'API Key only', scopeBedrock: 'Bedrock only', - userIds: 'Specific user IDs', - userIdsHint: 'Leave empty to apply to all Sub2API users. Specified users match requests from their API keys and take precedence over global rules.', - userIdPlaceholder: 'e.g., 1001', - addUserId: 'Add user ID', - removeUserId: 'Remove user ID', errorMessage: 'Error message', errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for default message', @@ -1024,6 +1019,13 @@ export default { scopeOAuth: 'OAuth only', scopeAPIKey: 'API Key only', scopeBedrock: 'Bedrock only', + userIds: 'Specific users', + userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.', + userSearchPlaceholder: 'Search by user email', + userSearchEmpty: 'No matching users found', + userDeleted: '(deleted)', + userIdFallback: 'User #{id}', + removeUser: 'Remove user', errorMessage: 'Error message', errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for the default message.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5c0d874b57..08c0dbcd53 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -974,11 +974,6 @@ export default { scopeOAuth: '仅 OAuth 账号', scopeAPIKey: '仅 API Key 账号', scopeBedrock: '仅 Bedrock 账号', - userIds: '指定用户 ID', - userIdsHint: '留空表示对全部 Sub2API 用户生效。指定后仅匹配这些用户的 API Key 请求,且优先于全局规则。', - userIdPlaceholder: '例如: 1001', - addUserId: '添加用户 ID', - removeUserId: '移除用户 ID', errorMessage: '错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息', @@ -1019,6 +1014,13 @@ export default { scopeOAuth: '仅 OAuth 账号', scopeAPIKey: '仅 API Key 账号', scopeBedrock: '仅 Bedrock 账号', + userIds: '指定用户', + userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。', + userSearchPlaceholder: '输入用户邮箱搜索', + userSearchEmpty: '未找到匹配用户', + userDeleted: '(已删除)', + userIdFallback: '用户 #{id}', + removeUser: '移除用户', errorMessage: '错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index a87cea9060..26253c7eb8 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1199,60 +1199,10 @@

{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}

-
- - -
- + @@ -7431,6 +7381,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue"; import ImageUpload from "@/components/common/ImageUpload.vue"; import BackupSettings from "@/views/admin/BackupView.vue"; import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue"; +import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue"; import { useClipboard } from "@/composables/useClipboard"; import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates"; import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError"; @@ -10226,18 +10177,6 @@ function removeOpenAIFastPolicyRule(index: number) { openaiFastPolicyForm.rules.splice(index, 1); } -function addOpenAIFastPolicyUserID(rule: OpenAIFastPolicyRule) { - if (!rule.user_ids) rule.user_ids = []; - rule.user_ids.push(0); -} - -function removeOpenAIFastPolicyUserID( - rule: OpenAIFastPolicyRule, - idx: number, -) { - rule.user_ids?.splice(idx, 1); -} - function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) { if (!rule.model_whitelist) rule.model_whitelist = []; rule.model_whitelist.push(""); diff --git a/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue b/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue new file mode 100644 index 0000000000..0b25c876ad --- /dev/null +++ b/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue @@ -0,0 +1,229 @@ + + + diff --git a/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts b/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts new file mode 100644 index 0000000000..c8e1f0e1cf --- /dev/null +++ b/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts @@ -0,0 +1,112 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import OpenAIFastPolicyUserSelector from '../OpenAIFastPolicyUserSelector.vue' + +const messages: Record = { + 'admin.settings.openaiFastPolicy.userDeleted': '(deleted)', + 'admin.settings.openaiFastPolicy.userIdFallback': 'User #{id}', + 'admin.settings.openaiFastPolicy.removeUser': 'Remove user', + 'admin.settings.openaiFastPolicy.userSearchPlaceholder': 'Search users', + 'admin.settings.openaiFastPolicy.userSearchEmpty': 'No users found', + 'common.loading': 'Loading', +} + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + const message = messages[key] ?? key + return params + ? Object.entries(params).reduce( + (value, [name, replacement]) => value.replace(`{${name}}`, String(replacement)), + message, + ) + : message + }, + }), +})) + +const mockSearchUsers = vi.fn() +const mockGetUserById = vi.fn() + +vi.mock('@/api/admin', () => ({ + adminAPI: { + usage: { + searchUsers: (...args: unknown[]) => mockSearchUsers(...args), + }, + users: { + getById: (...args: unknown[]) => mockGetUserById(...args), + }, + }, +})) + +describe('OpenAIFastPolicyUserSelector', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSearchUsers.mockReset() + mockGetUserById.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydrates existing IDs to email labels without changing the saved IDs', async () => { + mockGetUserById.mockResolvedValue({ + id: 7, + email: 'existing@example.com', + deleted_at: null, + }) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [7] }, + global: { stubs: { Icon: true } }, + }) + await flushPromises() + + expect(mockGetUserById).toHaveBeenCalledWith(7, true) + expect(wrapper.text()).toContain('existing@example.com') + expect(wrapper.text()).toContain('#7') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('searches after one character and adds the selected user ID', async () => { + mockSearchUsers.mockResolvedValue([ + { id: 9, email: 'alice@example.com', deleted: false }, + ]) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [] }, + global: { stubs: { Icon: true } }, + }) + const input = wrapper.get('input') + await input.trigger('focus') + await input.setValue('a') + await input.trigger('input') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(mockSearchUsers).toHaveBeenCalledWith('a') + const result = wrapper.findAll('button').find((button) => + button.text().includes('alice@example.com'), + ) + expect(result).toBeDefined() + await result!.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([[[9]]]) + }) + + it('keeps an unresolved saved ID visible and removable', async () => { + mockGetUserById.mockRejectedValue(new Error('not found')) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [42] }, + global: { stubs: { Icon: true } }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('User #42') + await wrapper.get('button[aria-label="Remove user"]').trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[[]]]) + }) +}) From 4d4ba64bf7ba110241e0850bee2dd4180a6b3f49 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 10 Jul 2026 23:32:34 +0800 Subject: [PATCH 014/104] =?UTF-8?q?fix(codex):=20=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=E7=BB=AD=E9=93=BE=20message=20item=20=E7=9A=84=E9=9D=9E?= =?UTF-8?q?=E6=B3=95=20item=5F*=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI OAuth 转发续链请求时,type=message 的 item 的 id 被客户端以 item_* 形式回放,但上游要求以 msg 开头,返回 400 "Expected an ID that begins with 'msg'",sub2api 随后向客户端返回 502。 客户端自动重试会不断重放同一份坏上下文,导致连续失败。 filterCodexInputWithOptions 在 PreserveReferences=true 路径下为 type=message 增加与 #3785 (fd64d07e6) 平行的 id 前缀检查:非 msg 开头 即删除。合法的 msg* id 原样保留,不改写 item_* 为 msg_*,因为改写出的 id 未必对应真实上游对象。 原 TestFilterCodexInput_NonToolCallItemKeepsID 以 message + item_msg_001 断言"保留 id",该行为已被上游拒绝,改用 web_search_call 覆盖同一意图。 Fixes #3981 Co-Authored-By: Claude Opus 4.8 --- .../openai_codex_function_call_id_test.go | 15 +- .../openai_codex_message_item_id_test.go | 160 ++++++++++++++++++ .../service/openai_codex_transform.go | 9 + 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/openai_codex_message_item_id_test.go diff --git a/backend/internal/service/openai_codex_function_call_id_test.go b/backend/internal/service/openai_codex_function_call_id_test.go index 2ac59e0520..edfcac1d71 100644 --- a/backend/internal/service/openai_codex_function_call_id_test.go +++ b/backend/internal/service/openai_codex_function_call_id_test.go @@ -114,14 +114,15 @@ func TestFilterCodexInput_OutputTypeKeepsItemID(t *testing.T) { require.Equal(t, "o1", out["id"], "output item id should be preserved") } -// TestFilterCodexInput_NonToolCallItemKeepsID ensures non-tool-call items -// (e.g. message) still keep their id when PreserveReferences is true. +// TestFilterCodexInput_NonToolCallItemKeepsID ensures items subject to neither +// the fc* (call-input) nor the msg* (message) prefix rule still keep their id +// when PreserveReferences is true. +// message is covered separately in openai_codex_message_item_id_test.go (#3981). func TestFilterCodexInput_NonToolCallItemKeepsID(t *testing.T) { input := []any{ map[string]any{ - "type": "message", - "id": "item_msg_001", - "role": "user", + "type": "web_search_call", + "id": "ws_001", }, } @@ -130,7 +131,7 @@ func TestFilterCodexInput_NonToolCallItemKeepsID(t *testing.T) { }) require.Len(t, filtered, 1) - msg, ok := filtered[0].(map[string]any) + item, ok := filtered[0].(map[string]any) require.True(t, ok) - require.Equal(t, "item_msg_001", msg["id"], "non-tool-call items keep their id in preserve mode") + require.Equal(t, "ws_001", item["id"], "unconstrained items keep their id in preserve mode") } diff --git a/backend/internal/service/openai_codex_message_item_id_test.go b/backend/internal/service/openai_codex_message_item_id_test.go new file mode 100644 index 0000000000..54c2a9ef22 --- /dev/null +++ b/backend/internal/service/openai_codex_message_item_id_test.go @@ -0,0 +1,160 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestFilterCodexInput_StripsMessageItemID_WhenPreservingReferences +// verifies that message items with a non-msg id (e.g. item_*) have their id +// stripped even when PreserveReferences is true. OpenAI upstream requires +// message ids to begin with "msg" and rejects item_* with 400: +// "Expected an ID that begins with 'msg'." (#3981) +func TestFilterCodexInput_StripsMessageItemID_WhenPreservingReferences(t *testing.T) { + input := []any{ + map[string]any{ + "type": "message", + "id": "item_3bc5a3fa8ccde25f1c0000d4", + "role": "user", + "content": []any{ + map[string]any{"type": "input_text", "text": "hello"}, + }, + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + + msg, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "message", msg["type"]) + _, hasID := msg["id"] + require.False(t, hasID, "item_* id should be stripped from message") + require.Equal(t, "user", msg["role"], "role must be preserved") + require.NotNil(t, msg["content"], "content must be preserved") +} + +// TestFilterCodexInput_KeepsMsgID_WhenPreservingReferences +// verifies that message items with a valid msg* id are kept when +// PreserveReferences is true, so context references are not lost. +func TestFilterCodexInput_KeepsMsgID_WhenPreservingReferences(t *testing.T) { + input := []any{ + map[string]any{ + "type": "message", + "id": "msg_validID123", + "role": "assistant", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + msg, ok := filtered[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "msg_validID123", msg["id"], "valid msg* id must be preserved") +} + +// TestFilterCodexInput_StripsMessageIDWhenNotPreservingReferences ensures the +// non-continuation path still drops every message id regardless of prefix. +func TestFilterCodexInput_StripsMessageIDWhenNotPreservingReferences(t *testing.T) { + for _, id := range []string{"item_abc", "msg_validID123"} { + input := []any{ + map[string]any{ + "type": "message", + "id": id, + "role": "user", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: false, + }) + + require.Len(t, filtered, 1) + msg, ok := filtered[0].(map[string]any) + require.True(t, ok) + _, hasID := msg["id"] + require.False(t, hasID, "id %q should be stripped when not preserving references", id) + } +} + +// TestFilterCodexInput_MessageIDStripDoesNotMutateInput ensures the original +// input map is not modified in place when the id is stripped. +func TestFilterCodexInput_MessageIDStripDoesNotMutateInput(t *testing.T) { + original := map[string]any{ + "type": "message", + "id": "item_abc", + "role": "user", + } + + filtered := filterCodexInputWithOptions([]any{original}, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 1) + require.Equal(t, "item_abc", original["id"], "original input must not be mutated") +} + +// TestFilterCodexInput_MessageStripKeepsFunctionCallBehavior guards against a +// regression of #3785: message and function_call id rules are independent. +func TestFilterCodexInput_MessageStripKeepsFunctionCallBehavior(t *testing.T) { + input := []any{ + map[string]any{ + "type": "message", + "id": "item_msg_001", + "role": "user", + }, + map[string]any{ + "type": "function_call", + "id": "fc_validID123", + "call_id": "fc_validID123", + "name": "bash", + }, + map[string]any{ + "type": "function_call", + "id": "item_A9v0SNfS3VaLrfX0j3y4xhyK", + "call_id": "fc_abc123", + "name": "bash", + }, + map[string]any{ + "type": "function_call_output", + "id": "o1", + "call_id": "fc_abc123", + "output": "done", + }, + } + + filtered := filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: true, + }) + + require.Len(t, filtered, 4) + + msg, ok := filtered[0].(map[string]any) + require.True(t, ok) + _, hasID := msg["id"] + require.False(t, hasID, "message item_* id should be stripped") + + fcValid, ok := filtered[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "fc_validID123", fcValid["id"], "valid fc* id must be preserved") + + fcBad, ok := filtered[2].(map[string]any) + require.True(t, ok) + _, hasID = fcBad["id"] + require.False(t, hasID, "function_call item_* id should still be stripped") + require.Equal(t, "fc_abc123", fcBad["call_id"], "call_id pairing must survive") + + out, ok := filtered[3].(map[string]any) + require.True(t, ok) + require.Equal(t, "o1", out["id"], "output item id should be preserved") + require.Equal(t, "fc_abc123", out["call_id"], "call_id pairing must survive") +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 99355628f2..3869e97c99 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1405,6 +1405,15 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an ensureCopy() delete(newItem, "id") } + } else if typ == "message" { + // 同理,message 类 item 的 id 必须以 "msg" 开头(上游校验 + // "Expected an ID that begins with 'msg'")。item_* 形式的 id + // 来自客户端回放,需要删除。 + // 注意:不改写成 msg_*,改写出的 id 未必对应真实的上游对象。 + if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "msg") { + ensureCopy() + delete(newItem, "id") + } } filtered = append(filtered, newItem) From 6e2bb312812b214751e7602cf48271ab9efefbcb Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 11 Jul 2026 08:52:46 +0800 Subject: [PATCH 015/104] fix(service): guard compact keepalive writer delegates --- .../service/openai_compact_sse_keepalive.go | 56 ++++++++++ .../openai_compact_sse_keepalive_test.go | 105 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/backend/internal/service/openai_compact_sse_keepalive.go b/backend/internal/service/openai_compact_sse_keepalive.go index 70ef3fc01a..4d4543f477 100644 --- a/backend/internal/service/openai_compact_sse_keepalive.go +++ b/backend/internal/service/openai_compact_sse_keepalive.go @@ -1,6 +1,9 @@ package service import ( + "bufio" + "errors" + "net" "net/http" "sync" "time" @@ -181,52 +184,105 @@ type openAICompactKeepaliveWriter struct { // suspend 停拍心跳;幂等。任何响应构造(含 Header 访问——写响应必先操作 // 响应头)都视为请求侧接管 ResponseWriter。 func (w *openAICompactKeepaliveWriter) suspend() { + if w.k == nil { + return + } w.k.Stop() } func (w *openAICompactKeepaliveWriter) Header() http.Header { w.suspend() + if w.ResponseWriter == nil { + return http.Header{} + } return w.ResponseWriter.Header() } func (w *openAICompactKeepaliveWriter) Write(data []byte) (int, error) { w.suspend() + if w.ResponseWriter == nil { + return 0, nil + } return w.ResponseWriter.Write(data) } func (w *openAICompactKeepaliveWriter) WriteString(s string) (int, error) { w.suspend() + if w.ResponseWriter == nil { + return 0, nil + } return w.ResponseWriter.WriteString(s) } func (w *openAICompactKeepaliveWriter) WriteHeader(code int) { w.suspend() + if w.ResponseWriter == nil { + return + } w.ResponseWriter.WriteHeader(code) } func (w *openAICompactKeepaliveWriter) WriteHeaderNow() { w.suspend() + if w.ResponseWriter == nil { + return + } w.ResponseWriter.WriteHeaderNow() } func (w *openAICompactKeepaliveWriter) Flush() { w.suspend() + if w.ResponseWriter == nil { + return + } w.ResponseWriter.Flush() } +func (w *openAICompactKeepaliveWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if w.ResponseWriter == nil { + return nil, nil, errors.New("response writer released") + } + return w.ResponseWriter.Hijack() +} + +func (w *openAICompactKeepaliveWriter) CloseNotify() <-chan bool { + if w.ResponseWriter == nil { + ch := make(chan bool) + close(ch) + return ch + } + return w.ResponseWriter.CloseNotify() +} + +func (w *openAICompactKeepaliveWriter) Pusher() http.Pusher { + if w.ResponseWriter == nil { + return nil + } + return w.ResponseWriter.Pusher() +} + func (w *openAICompactKeepaliveWriter) Status() int { + if w.k == nil || w.ResponseWriter == nil { + return 0 + } w.k.mu.Lock() defer w.k.mu.Unlock() return w.ResponseWriter.Status() } func (w *openAICompactKeepaliveWriter) Size() int { + if w.k == nil || w.ResponseWriter == nil { + return 0 + } w.k.mu.Lock() defer w.k.mu.Unlock() return w.ResponseWriter.Size() } func (w *openAICompactKeepaliveWriter) Written() bool { + if w.k == nil || w.ResponseWriter == nil { + return false + } w.k.mu.Lock() defer w.k.mu.Unlock() return w.ResponseWriter.Written() diff --git a/backend/internal/service/openai_compact_sse_keepalive_test.go b/backend/internal/service/openai_compact_sse_keepalive_test.go index 3b217a0718..1efed7e9e4 100644 --- a/backend/internal/service/openai_compact_sse_keepalive_test.go +++ b/backend/internal/service/openai_compact_sse_keepalive_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -141,6 +142,110 @@ func TestOpenAICompactKeepaliveWriter_RequestSideWriteSuspendsBeats(t *testing.T require.Contains(t, rec.Body.String(), `{"error":"local reject"}`) } +func TestOpenAICompactKeepaliveWriter_NilInnerWriter_NoPanic(t *testing.T) { + w := &openAICompactKeepaliveWriter{ + k: &openAICompactSSEKeepalive{stop: make(chan struct{})}, + } + w.ResponseWriter = nil + + assert.NotPanics(t, func() { + assert.Equal(t, 0, w.Status()) + }) + assert.NotPanics(t, func() { + assert.Equal(t, 0, w.Size()) + }) + assert.NotPanics(t, func() { + assert.False(t, w.Written()) + }) + assert.NotPanics(t, func() { + assert.NotNil(t, w.Header()) + }) + assert.NotPanics(t, func() { + n, err := w.Write([]byte("test")) + assert.Equal(t, 0, n) + assert.NoError(t, err) + }) + assert.NotPanics(t, func() { + n, err := w.WriteString("test") + assert.Equal(t, 0, n) + assert.NoError(t, err) + }) + assert.NotPanics(t, func() { + w.WriteHeader(http.StatusOK) + }) + assert.NotPanics(t, func() { + w.WriteHeaderNow() + }) + assert.NotPanics(t, func() { + w.Flush() + }) + assert.NotPanics(t, func() { + conn, rw, err := w.Hijack() + assert.Nil(t, conn) + assert.Nil(t, rw) + assert.Error(t, err) + }) + assert.NotPanics(t, func() { + ch := w.CloseNotify() + assert.NotNil(t, ch) + }) + assert.NotPanics(t, func() { + assert.Nil(t, w.Pusher()) + }) +} + +func TestOpenAICompactKeepaliveWriter_NilKeepalive_NoPanic(t *testing.T) { + c, rec := newCompactBridgeTestContext(t, true) + w := &openAICompactKeepaliveWriter{ResponseWriter: c.Writer} + + assert.NotPanics(t, func() { + assert.Equal(t, 0, w.Status()) + }) + assert.NotPanics(t, func() { + assert.Equal(t, 0, w.Size()) + }) + assert.NotPanics(t, func() { + assert.False(t, w.Written()) + }) + assert.NotPanics(t, func() { + w.Header().Set("X-Test", "ok") + }) + assert.NotPanics(t, func() { + w.WriteHeader(http.StatusAccepted) + }) + assert.NotPanics(t, func() { + n, err := w.WriteString("ok") + assert.Equal(t, 2, n) + assert.NoError(t, err) + }) + assert.NotPanics(t, func() { + w.Flush() + }) + require.Equal(t, "ok", rec.Header().Get("X-Test")) + require.Equal(t, "ok", rec.Body.String()) +} + +func TestOpenAICompactKeepaliveWriter_DelegatesWhenReady(t *testing.T) { + c, rec := newCompactBridgeTestContext(t, true) + stop := StartOpenAICompactSSEKeepalive(c, time.Hour) + defer stop() + + w, ok := c.Writer.(*openAICompactKeepaliveWriter) + require.True(t, ok) + + w.Header().Set("X-Test", "ok") + w.WriteHeader(http.StatusAccepted) + n, err := w.WriteString("ready") + require.NoError(t, err) + require.Equal(t, len("ready"), n) + + require.Equal(t, http.StatusAccepted, w.Status()) + require.Equal(t, len("ready"), w.Size()) + require.True(t, w.Written()) + require.Equal(t, "ok", rec.Header().Get("X-Test")) + require.Equal(t, "ready", rec.Body.String()) +} + // fast policy block 在心跳提交后必须降级为 response.failed 终止事件。 func TestWriteOpenAIFastPolicyBlockedResponse_AfterKeepaliveCommit(t *testing.T) { c, rec := newCompactBridgeTestContext(t, true) From 84bb7d070974dc9ee12dcca3d263a87cb4a58430 Mon Sep 17 00:00:00 2001 From: Tian Lee <498756723@qq.com> Date: Fri, 10 Jul 2026 15:32:04 +0800 Subject: [PATCH 016/104] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20remote=5Fco?= =?UTF-8?q?mpaction=5Fv2=20=E5=8E=9F=E7=94=9F=20Responses=20=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...openai_gateway_compact_body_signal_test.go | 142 +++++++++------ .../handler/openai_gateway_handler.go | 30 +++- .../service/openai_compact_body_signal.go | 16 +- .../service/openai_gateway_service.go | 2 + .../internal/service/openai_gpt56_max_test.go | 81 ++++++++- .../service/openai_oauth_passthrough_test.go | 4 + .../service/openai_ws_forwarder_payload.go | 5 + .../openai_ws_forwarder_success_test.go | 2 + backend/internal/service/openai_ws_pool.go | 141 ++++++++++++++- .../internal/service/openai_ws_pool_test.go | 165 ++++++++++++++++++ 10 files changed, 505 insertions(+), 83 deletions(-) diff --git a/backend/internal/handler/openai_gateway_compact_body_signal_test.go b/backend/internal/handler/openai_gateway_compact_body_signal_test.go index a4bfb90466..a44d47c856 100644 --- a/backend/internal/handler/openai_gateway_compact_body_signal_test.go +++ b/backend/internal/handler/openai_gateway_compact_body_signal_test.go @@ -23,46 +23,61 @@ func newCompactBodySignalTestContext(t *testing.T, path string, body []byte) *gi return c } -// body-signal 提升后必须与 path-based compact 走同一条链路: -// path 改写、requireCompact 判定、stream/store/prompt_cache_key 归一化删除。 -// 回归防护:若 stream 字段存活,Forward 会用流式 handler 解析 compact 的 -// JSON 响应,导致 "stream ended before a terminal event" 的换号 failover 风暴。 -func TestNormalizeOpenAIResponsesCompactRequest_BodySignalPromoted(t *testing.T) { +func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2StaysOnResponses(t *testing.T) { h := &OpenAIGatewayHandler{} body := []byte(`{ - "model":"gpt-5.5", + "model":"gpt-5.6-sol", "stream":true, "store":true, "prompt_cache_key":"pck-signal-1", + "reasoning":{"effort":"max","context":"all_turns"}, "input":[ {"type":"message","role":"user","content":"hello"}, {"type":"compaction_trigger"} ] }`) c := newCompactBodySignalTestContext(t, "/v1/responses", body) + c.Request.Header.Set("x-codex-beta-features", "responses_websockets_v2, remote_compaction_v2, another_feature") normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) require.True(t, ok) - require.Equal(t, "/v1/responses/compact", c.Request.URL.Path) - require.True(t, isOpenAIRemoteCompactPath(c)) - - require.False(t, gjson.GetBytes(normalized, "stream").Exists()) - require.False(t, gjson.GetBytes(normalized, "store").Exists()) - require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists()) - require.Equal(t, "gpt-5.5", gjson.GetBytes(normalized, "model").String()) - require.True(t, gjson.GetBytes(normalized, "input").IsArray()) + require.Equal(t, "/v1/responses", c.Request.URL.Path) + require.False(t, isOpenAIRemoteCompactPath(c)) + require.Equal(t, body, normalized) + require.True(t, gjson.GetBytes(normalized, "stream").Bool()) + require.True(t, gjson.GetBytes(normalized, "store").Bool()) + require.Equal(t, "pck-signal-1", gjson.GetBytes(normalized, "prompt_cache_key").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.GetBytes(normalized, "reasoning.context").String()) reqStream, streamOK := parseOpenAICompatibleStream(normalized) require.True(t, streamOK) - require.False(t, reqStream) + require.True(t, reqStream) - seed, exists := c.Get(service.OpenAICompactSessionSeedKeyForTest()) - require.True(t, exists) - require.Equal(t, "pck-signal-1", seed) + _, seedExists := c.Get(service.OpenAICompactSessionSeedKeyForTest()) + require.False(t, seedExists) + _, streamMarkerExists := c.Get(service.OpenAICompactClientStreamKeyForTest()) + require.False(t, streamMarkerExists) } -func TestNormalizeOpenAIResponsesCompactRequest_BodySignalTrailingSlash(t *testing.T) { +func TestNormalizeOpenAIResponsesCompactRequest_RemoteV2PathAliasesStayOnResponses(t *testing.T) { + h := &OpenAIGatewayHandler{} + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"compaction_trigger"}]}`) + for _, path := range []string{"/v1/responses/", "/backend-api/codex/responses"} { + t.Run(path, func(t *testing.T) { + c := newCompactBodySignalTestContext(t, path, body) + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") + + normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) + require.True(t, ok) + require.Equal(t, path, c.Request.URL.Path) + require.Equal(t, body, normalized) + }) + } +} + +func TestNormalizeOpenAIResponsesCompactRequest_BodySignalTrailingSlashPromoted(t *testing.T) { h := &OpenAIGatewayHandler{} body := []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`) c := newCompactBodySignalTestContext(t, "/v1/responses/", body) @@ -82,6 +97,64 @@ func TestNormalizeOpenAIResponsesCompactRequest_CodexDirectAliasPromoted(t *test require.Equal(t, "/backend-api/codex/responses/compact", c.Request.URL.Path) } +func TestNormalizeOpenAIResponsesCompactRequest_NonRemoteV2BodySignalPromoted(t *testing.T) { + h := &OpenAIGatewayHandler{} + tests := []struct { + name string + body []byte + betaHeader string + wantMarked bool + }{ + { + name: "no_header", + body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), + wantMarked: true, + }, + { + name: "unrelated_header", + body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), + betaHeader: "responses_websockets_v2", + wantMarked: true, + }, + { + name: "wrong_case_header", + body: []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`), + betaHeader: "REMOTE_COMPACTION_V2", + wantMarked: true, + }, + { + name: "stream_false", + body: []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`), + betaHeader: "remote_compaction_v2", + }, + { + name: "stream_absent", + body: []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`), + betaHeader: "remote_compaction_v2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newCompactBodySignalTestContext(t, "/v1/responses", tt.body) + if tt.betaHeader != "" { + c.Request.Header.Set("x-codex-beta-features", tt.betaHeader) + } + + normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), tt.body) + require.True(t, ok) + require.Equal(t, "/v1/responses/compact", c.Request.URL.Path) + require.False(t, gjson.GetBytes(normalized, "stream").Exists()) + + marked, exists := c.Get(service.OpenAICompactClientStreamKeyForTest()) + require.Equal(t, tt.wantMarked, exists) + if tt.wantMarked { + require.Equal(t, true, marked) + } + }) + } +} + func TestNormalizeOpenAIResponsesCompactRequest_NoTriggerUntouched(t *testing.T) { h := &OpenAIGatewayHandler{} body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"}]}`) @@ -99,6 +172,7 @@ func TestNormalizeOpenAIResponsesCompactRequest_PathBasedNoDoubleSuffix(t *testi h := &OpenAIGatewayHandler{} body := []byte(`{"model":"gpt-5.5","stream":true,"store":true,"input":[{"type":"message","role":"user","content":"hello"}]}`) c := newCompactBodySignalTestContext(t, "/v1/responses/compact", body) + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") normalized, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) require.True(t, ok) @@ -118,36 +192,6 @@ func TestNormalizeOpenAIResponsesCompactRequest_SubpathNotPromoted(t *testing.T) require.Equal(t, body, normalized) } -// 回归 #3875:body-signal 原始请求 stream:true 时必须标记 client-stream, -// 供响应写回阶段把上游 unary JSON 合成回 Codex remote compact v2 所需的 SSE。 -func TestNormalizeOpenAIResponsesCompactRequest_BodySignalStreamTrueMarksClientStream(t *testing.T) { - h := &OpenAIGatewayHandler{} - body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"compaction_trigger"}]}`) - c := newCompactBodySignalTestContext(t, "/v1/responses", body) - - _, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) - require.True(t, ok) - - marked, exists := c.Get(service.OpenAICompactClientStreamKeyForTest()) - require.True(t, exists) - require.Equal(t, true, marked) -} - -func TestNormalizeOpenAIResponsesCompactRequest_BodySignalStreamFalseNotMarked(t *testing.T) { - h := &OpenAIGatewayHandler{} - for name, body := range map[string][]byte{ - "stream_false": []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"compaction_trigger"}]}`), - "stream_absent": []byte(`{"model":"gpt-5.5","input":[{"type":"compaction_trigger"}]}`), - } { - c := newCompactBodySignalTestContext(t, "/v1/responses", body) - _, ok := h.normalizeOpenAIResponsesCompactRequest(c, zap.NewNop(), body) - require.True(t, ok, name) - require.Equal(t, "/v1/responses/compact", c.Request.URL.Path, name) - _, exists := c.Get(service.OpenAICompactClientStreamKeyForTest()) - require.False(t, exists, "case %s 不应标记 client-stream", name) - } -} - // path-based compact(Codex v1 unary 协议)即使 body 带 stream:true 也不标记, // 保持 JSON 写回行为不变。 func TestNormalizeOpenAIResponsesCompactRequest_PathBasedStreamTrueNotMarked(t *testing.T) { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 83e644d857..a4d7ee7b18 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -580,21 +580,33 @@ func isBareOpenAIResponsesPath(c *gin.Context) bool { return strings.HasSuffix(normalizedPath, "/responses") } -// normalizeOpenAIResponsesCompactRequest 统一处理两种入站 compact 形态: -// path-based(POST /v1/responses/compact)与 Codex remote compact v2 的 -// body-signal(普通 POST /v1/responses 的 input 中携带 type=compaction_trigger, -// 见 #3777)。body-signal 命中时在 stream 解析、compact body 归一化与 -// requireCompact 调度判定之前改写 URL path,使后续全部链路(含 passthrough -// 分支与上游 URL 构建)与 path-based 完全一致。 +func isOpenAIRemoteCompactionV2Request(c *gin.Context, body []byte) bool { + stream, valid := parseOpenAICompatibleStream(body) + if !valid || !stream || c == nil || c.Request == nil { + return false + } + for _, header := range c.Request.Header.Values("x-codex-beta-features") { + for _, feature := range strings.Split(header, ",") { + if strings.TrimSpace(feature) == "remote_compaction_v2" { + return true + } + } + } + return false +} + +// normalizeOpenAIResponsesCompactRequest keeps Codex remote compaction v2 on +// its native streaming /responses wire and preserves the legacy body-signal +// promotion for clients that do not explicitly advertise that protocol. // 返回归一化后的 body;ok=false 表示错误响应已写出,调用方应直接 return。 func (h *OpenAIGatewayHandler) normalizeOpenAIResponsesCompactRequest(c *gin.Context, reqLog *zap.Logger, body []byte) ([]byte, bool) { isCompactRequest := service.IsOpenAIResponsesCompactPathForTest(c) if !isCompactRequest && isBareOpenAIResponsesPath(c) && service.HasCompactionTriggerInInput(body) { + if isOpenAIRemoteCompactionV2Request(c, body) { + return body, true + } c.Request.URL.Path = strings.TrimRight(c.Request.URL.Path, "/") + "/compact" isCompactRequest = true - // Codex remote compact v2 的原始请求是流式 /responses:白名单归一化会删除 - // stream 并让上游走 unary JSON,但客户端仍按 SSE 消费响应。记录原始 - // stream 意图,响应写回阶段据此把 JSON 合成回 SSE(#3875)。 clientStream := gjson.GetBytes(body, "stream").Bool() if clientStream { service.MarkOpenAICompactClientStream(c) diff --git a/backend/internal/service/openai_compact_body_signal.go b/backend/internal/service/openai_compact_body_signal.go index fce62046c1..ce561b0c5a 100644 --- a/backend/internal/service/openai_compact_body_signal.go +++ b/backend/internal/service/openai_compact_body_signal.go @@ -2,18 +2,10 @@ package service import "github.com/tidwall/gjson" -// HasCompactionTriggerInInput detects the Codex remote compact v2 body signal: -// an input item with type "compaction_trigger". When the client sends this -// inside a normal POST /v1/responses (instead of POST /v1/responses/compact), -// the request must still be treated as a compact request — otherwise the -// upstream path, model mapping, and body normalization are all wrong, causing -// Codex to receive a non-compact response and fail with: -// -// "remote compaction v2 expected exactly one compaction output item, got 0" -// -// The gateway handler promotes such requests by rewriting the URL path to the -// compact form before stream parsing, compact body normalization, and -// compact-capable account scheduling, so both inbound forms share one code path. +// HasCompactionTriggerInInput detects an input item with +// type="compaction_trigger". The handler combines this body signal with the +// request path, stream flag, and Codex beta feature header to distinguish the +// native remote compaction v2 wire from the legacy /responses/compact bridge. func HasCompactionTriggerInInput(body []byte) bool { if len(body) == 0 { return false diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0d03d83dcf..c3b1b996f1 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -66,6 +66,7 @@ var openaiAllowedHeaders = map[string]bool{ "user-agent": true, "originator": true, "session_id": true, + "x-codex-beta-features": true, "x-codex-turn-state": true, "x-codex-turn-metadata": true, } @@ -81,6 +82,7 @@ var openaiPassthroughAllowedHeaders = map[string]bool{ "user-agent": true, "originator": true, "session_id": true, + "x-codex-beta-features": true, "x-codex-turn-state": true, "x-codex-turn-metadata": true, } diff --git a/backend/internal/service/openai_gpt56_max_test.go b/backend/internal/service/openai_gpt56_max_test.go index 272eb16ff0..cbca2ff3ee 100644 --- a/backend/internal/service/openai_gpt56_max_test.go +++ b/backend/internal/service/openai_gpt56_max_test.go @@ -223,13 +223,17 @@ func TestOpenAIGatewayServiceForwardOAuthCompactDowngradesMaxEffort(t *testing.T require.Equal(t, "xhigh", *result.ReasoningEffort) } -func TestOpenAIGatewayServiceForwardOAuthResponsesPreservesMaxEffort(t *testing.T) { +func TestOpenAIGatewayServiceForwardOAuthRemoteCompactV2PreservesResponsesWire(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ resp: &http.Response{ StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" + + "data: [DONE]\n\n", + )), }, } cfg := &config.Config{} @@ -244,6 +248,9 @@ func TestOpenAIGatewayServiceForwardOAuthResponsesPreservesMaxEffort(t *testing. Credentials: map[string]any{ "access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc", + "compact_model_mapping": map[string]any{ + "gpt-5.6-sol": "gpt-5.6-sol-openai-compact", + }, }, Status: StatusActive, Schedulable: true, @@ -251,16 +258,82 @@ func TestOpenAIGatewayServiceForwardOAuthResponsesPreservesMaxEffort(t *testing. rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) - body := []byte(`{"model":"gpt-5.6-sol","instructions":"response-test","input":"hello","reasoning":{"effort":"max"}}`) + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"response-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}],"reasoning":{"effort":"max","context":"all_turns"}}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, upstream.lastReq) require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String()) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String()) + require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) + require.Contains(t, rec.Body.String(), `"type":"compaction"`) + require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) +} + +func TestOpenAIGatewayServiceForwardAPIKeyRemoteCompactV2PreservesResponsesWire(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 11, + Name: "openai-apikey-responses", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com/v1", + "compact_model_mapping": map[string]any{ + "gpt-5.6-sol": "gpt-5.6-sol-openai-compact", + }, + }, + Extra: map[string]any{"use_responses_api": true}, + Status: StatusActive, + Schedulable: true, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"response-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}],"reasoning":{"effort":"max","context":"all_turns"}}`) + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://example.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String()) + require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) + require.Contains(t, rec.Body.String(), `"type":"compaction"`) + require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index de8ecf030b..60790b9e4c 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -347,6 +347,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali c.Request.Header.Set("Accept-Encoding", "gzip") c.Request.Header.Set("Proxy-Authorization", "Basic abc") c.Request.Header.Set("X-Test", "keep") + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`) @@ -409,6 +410,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali require.Empty(t, upstream.lastReq.Header.Get("Accept-Encoding")) require.Empty(t, upstream.lastReq.Header.Get("Proxy-Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Test")) + require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) // 3) required OAuth headers are present require.Equal(t, "chatgpt.com", upstream.lastReq.Host) @@ -1373,6 +1375,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) c.Request.Header.Set("User-Agent", "curl/8.0") c.Request.Header.Set("X-Test", "keep") + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") originalBody := []byte(`{"model":"gpt-5.2","stream":false,"service_tier":"flex","max_output_tokens":128,"input":[{"type":"text","text":"hi"}]}`) resp := &http.Response{ @@ -1410,6 +1413,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer sk-api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) require.Empty(t, upstream.lastReq.Header.Get("X-Test")) } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index a4d47218e7..5830444815 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -74,6 +74,11 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" { headers.Set("accept-language", v) } + for _, value := range c.Request.Header.Values("x-codex-beta-features") { + if value = strings.TrimSpace(value); value != "" { + headers.Add("x-codex-beta-features", value) + } + } } // OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。 if account != nil && account.Type == AccountTypeOAuth { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index adae109e09..bb4ac2242c 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -602,6 +602,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") c.Request.Header.Set("session_id", "sess-oauth-1") c.Request.Header.Set("conversation_id", "conv-oauth-1") + c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false @@ -661,6 +662,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.True(t, gjson.Get(requestJSON, "stream").Exists(), "WSv2 payload 应保留 stream 字段") require.True(t, gjson.Get(requestJSON, "stream").Bool(), "OAuth Codex 规范化后应强制 stream=true") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) + require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) // OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离, // 测试中未设置 api_key 到 context,apiKeyID=0。 require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 5950e02841..329908e762 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -218,6 +218,9 @@ func (l *openAIWSConnLease) Release() { return } l.conn.release() + if l.pool != nil { + l.pool.notifyAccountPoolChanged(l.accountID) + } } type openAIWSConn struct { @@ -225,6 +228,7 @@ type openAIWSConn struct { ws openAIWSClientConn handshakeHeaders http.Header + betaFeatures string leaseCh chan struct{} closedCh chan struct{} @@ -498,6 +502,10 @@ func (c *openAIWSConn) handshakeHeader(name string) string { return strings.TrimSpace(c.handshakeHeaders.Get(strings.TrimSpace(name))) } +func (c *openAIWSConn) matchesBetaFeatures(betaFeatures string) bool { + return c != nil && c.betaFeatures == betaFeatures +} + func (c *openAIWSConn) isPrewarmed() bool { if c == nil { return false @@ -516,6 +524,7 @@ type openAIWSAccountPool struct { mu sync.Mutex conns map[string]*openAIWSConn pinnedConns map[string]int + changedCh chan struct{} creating int lastCleanupAt time.Time lastAcquire *openAIWSAcquireRequest @@ -525,6 +534,23 @@ type openAIWSAccountPool struct { prewarmFailAt time.Time } +func (ap *openAIWSAccountPool) changeChannelLocked() chan struct{} { + if ap.changedCh == nil { + ap.changedCh = make(chan struct{}) + } + return ap.changedCh +} + +func (ap *openAIWSAccountPool) signalChangedLocked() { + if ap == nil { + return + } + if ap.changedCh != nil { + close(ap.changedCh) + } + ap.changedCh = make(chan struct{}) +} + type OpenAIWSPoolMetricsSnapshot struct { AcquireTotal int64 AcquireReuseTotal int64 @@ -786,7 +812,9 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque return nil, errors.New("ws url is empty") } +retryAcquire: accountID := req.Account.ID + betaFeatures := normalizeOpenAIWSBetaFeatures(req.Headers) effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account) if effectiveMaxConns <= 0 { return nil, errOpenAIWSConnQueueFull @@ -814,7 +842,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque return nil, errOpenAIWSPreferredConnUnavailable } preferredConn, ok := ap.conns[preferredConnID] - if !ok || preferredConn == nil { + if !ok || !preferredConn.matchesBetaFeatures(betaFeatures) { p.recordConnPickDuration(time.Since(pickStartedAt)) ap.mu.Unlock() closeOpenAIWSConns(evicted) @@ -895,7 +923,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque } if preferredConnID != "" { - if conn, ok := ap.conns[preferredConnID]; ok && conn.tryAcquire() { + if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) && conn.tryAcquire() { connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) ap.mu.Unlock() @@ -917,7 +945,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque } } - best := p.pickLeastBusyConnLocked(ap, "") + best := p.pickLeastBusyConnLocked(ap, "", betaFeatures) if best != nil && best.tryAcquire() { connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) @@ -939,7 +967,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque return lease, nil } for _, conn := range ap.conns { - if conn == nil || conn == best { + if conn == nil || conn == best || !conn.matchesBetaFeatures(betaFeatures) { continue } if conn.tryAcquire() { @@ -965,6 +993,37 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque } } + if !req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns { + compatible := p.pickLeastBusyConnLocked(ap, "", betaFeatures) + if idle := p.pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap, betaFeatures); idle != nil { + delete(ap.conns, idle.id) + evicted = append(evicted, idle) + p.metrics.scaleDownTotal.Add(1) + } else if compatible == nil { + hasConnection := false + for _, conn := range ap.conns { + if conn != nil { + hasConnection = true + break + } + } + if !hasConnection && ap.creating == 0 { + ap.mu.Unlock() + closeOpenAIWSConns(evicted) + return nil, errOpenAIWSConnClosed + } + changedCh := ap.changeChannelLocked() + ap.mu.Unlock() + closeOpenAIWSConns(evicted) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-changedCh: + goto retryAcquire + } + } + } + if req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns { if idle := p.pickOldestIdleConnLocked(ap); idle != nil { delete(ap.conns, idle.id) @@ -988,6 +1047,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque if dialErr != nil { ap.prewarmFails++ ap.prewarmFailAt = time.Now() + ap.signalChangedLocked() ap.mu.Unlock() return nil, dialErr } @@ -1016,7 +1076,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque return nil, errOpenAIWSConnQueueFull } - target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID) + target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID, betaFeatures) connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) if target == nil { @@ -1089,6 +1149,22 @@ func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *op return oldest } +func (p *openAIWSConnPool) pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap *openAIWSAccountPool, betaFeatures string) *openAIWSConn { + if ap == nil || len(ap.conns) == 0 { + return nil + } + var oldest *openAIWSConn + for _, conn := range ap.conns { + if conn == nil || conn.matchesBetaFeatures(betaFeatures) || conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) { + continue + } + if oldest == nil || conn.lastUsedAt().Before(oldest.lastUsedAt()) { + oldest = conn + } + } + return oldest +} + func (p *openAIWSConnPool) getOrCreateAccountPool(accountID int64) *openAIWSAccountPool { if p == nil || accountID <= 0 { return nil @@ -1101,6 +1177,7 @@ func (p *openAIWSConnPool) getOrCreateAccountPool(accountID int64) *openAIWSAcco ap := &openAIWSAccountPool{ conns: make(map[string]*openAIWSConn), pinnedConns: make(map[string]int), + changedCh: make(chan struct{}), } actual, _ := p.accounts.LoadOrStore(accountID, ap) if typed, ok := actual.(*openAIWSAccountPool); ok && typed != nil { @@ -1126,6 +1203,16 @@ func (p *openAIWSConnPool) getAccountPool(accountID int64) (*openAIWSAccountPool return ap, typed && ap != nil } +func (p *openAIWSConnPool) notifyAccountPoolChanged(accountID int64) { + ap, ok := p.getAccountPool(accountID) + if !ok || ap == nil { + return + } + ap.mu.Lock() + ap.signalChangedLocked() + ap.mu.Unlock() +} + func (p *openAIWSConnPool) isConnPinnedLocked(ap *openAIWSAccountPool, connID string) bool { if ap == nil || connID == "" || len(ap.pinnedConns) == 0 { return false @@ -1212,17 +1299,20 @@ func (p *openAIWSConnPool) cleanupAccountLocked(ap *openAIWSAccountPool, now tim p.metrics.scaleDownTotal.Add(int64(redundant)) } } + if len(evicted) > 0 { + ap.signalChangedLocked() + } return evicted } -func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, preferredConnID string) *openAIWSConn { +func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, preferredConnID, betaFeatures string) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil } preferredConnID = stringsTrim(preferredConnID) if preferredConnID != "" { - if conn, ok := ap.conns[preferredConnID]; ok { + if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) { return conn } } @@ -1230,7 +1320,7 @@ func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, pref var bestWaiters int32 var bestLastUsed time.Time for _, conn := range ap.conns { - if conn == nil { + if conn == nil || !conn.matchesBetaFeatures(betaFeatures) { continue } waiters := conn.waiters.Load() @@ -1395,10 +1485,12 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ if err != nil { ap.prewarmFails++ ap.prewarmFailAt = time.Now() + ap.signalChangedLocked() ap.mu.Unlock() continue } if len(ap.conns) >= p.effectiveMaxConnsByAccount(req.Account) { + ap.signalChangedLocked() ap.mu.Unlock() conn.close() continue @@ -1406,6 +1498,7 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ ap.conns[conn.id] = conn ap.prewarmFails = 0 ap.prewarmFailAt = time.Time{} + ap.signalChangedLocked() ap.mu.Unlock() } } @@ -1424,6 +1517,7 @@ func (p *openAIWSConnPool) evictConn(accountID int64, connID string) { if len(ap.pinnedConns) > 0 { delete(ap.pinnedConns, connID) } + ap.signalChangedLocked() } ap.mu.Unlock() } @@ -1476,9 +1570,11 @@ func (p *openAIWSConnPool) UnpinConn(accountID int64, connID string) { count := ap.pinnedConns[connID] if count <= 1 { delete(ap.pinnedConns, connID) + ap.signalChangedLocked() return } ap.pinnedConns[connID] = count - 1 + ap.signalChangedLocked() } func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequest) (*openAIWSConn, error) { @@ -1501,7 +1597,9 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ } } id := p.nextConnID(req.Account.ID) - return newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders), nil + pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders) + pooledConn.betaFeatures = normalizeOpenAIWSBetaFeatures(req.Headers) + return pooledConn, nil } func (p *openAIWSConnPool) nextConnID(accountID int64) string { @@ -1679,6 +1777,31 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir return &copied } +func normalizeOpenAIWSBetaFeatures(headers http.Header) string { + features := make(map[string]struct{}) + for name, values := range headers { + if !strings.EqualFold(strings.TrimSpace(name), "x-codex-beta-features") { + continue + } + for _, value := range values { + for _, feature := range strings.Split(value, ",") { + if feature = strings.TrimSpace(feature); feature != "" { + features[feature] = struct{}{} + } + } + } + } + if len(features) == 0 { + return "" + } + normalized := make([]string, 0, len(features)) + for feature := range features { + normalized = append(normalized, feature) + } + sort.Strings(normalized) + return strings.Join(normalized, ",") +} + func cloneHeader(src http.Header) http.Header { if src == nil { return nil diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index b2683ee041..ae9b94ce4a 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -342,6 +342,171 @@ func TestOpenAIWSConnPool_ForceNewConnSkipsReuse(t *testing.T) { require.Equal(t, 2, dialer.DialCount(), "ForceNewConn=true 时应跳过空闲连接复用并新建连接") } +func TestOpenAIWSConnPool_AcquireReusesOnlyMatchingBetaFeatures(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + + account := &Account{ID: 128, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + baseReq := openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + } + + plainLease, err := pool.Acquire(context.Background(), baseReq) + require.NoError(t, err) + plainConnID := plainLease.ConnID() + plainLease.Release() + + betaReq := baseReq + betaReq.Headers = http.Header{"X-Codex-Beta-Features": {" remote_compaction_v2 ", " responses_websockets_v2 "}} + betaLease, err := pool.Acquire(context.Background(), betaReq) + require.NoError(t, err) + require.False(t, betaLease.Reused()) + require.NotEqual(t, plainConnID, betaLease.ConnID()) + betaConnID := betaLease.ConnID() + betaLease.Release() + + reorderedReq := baseReq + reorderedReq.Headers = http.Header{"X-Codex-Beta-Features": {"responses_websockets_v2,remote_compaction_v2"}} + reorderedLease, err := pool.Acquire(context.Background(), reorderedReq) + require.NoError(t, err) + require.True(t, reorderedLease.Reused()) + require.Equal(t, betaConnID, reorderedLease.ConnID()) + reorderedLease.Release() + + _, err = pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: baseReq.WSURL, + Headers: betaReq.Headers, + PreferredConnID: plainConnID, + ForcePreferredConn: true, + }) + require.ErrorIs(t, err, errOpenAIWSPreferredConnUnavailable) + + plainLease, err = pool.Acquire(context.Background(), baseReq) + require.NoError(t, err) + require.True(t, plainLease.Reused()) + require.Equal(t, plainConnID, plainLease.ConnID()) + plainLease.Release() + + require.Equal(t, 2, dialer.DialCount()) +} + +func TestOpenAIWSConnPool_AcquireReplacesIdleConnWithDifferentBetaFeatures(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + + account := &Account{ID: 129, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + plainLease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + }) + require.NoError(t, err) + plainConnID := plainLease.ConnID() + plainLease.Release() + + betaLease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: http.Header{"X-Codex-Beta-Features": {"remote_compaction_v2"}}, + }) + require.NoError(t, err) + require.False(t, betaLease.Reused()) + require.NotEqual(t, plainConnID, betaLease.ConnID()) + betaLease.Release() + + require.Equal(t, 2, dialer.DialCount()) +} + +func TestOpenAIWSConnPool_AcquireWaitsForBusyIncompatibleConnection(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := &Account{ID: 130, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + baseReq := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"} + + plainLease, err := pool.Acquire(context.Background(), baseReq) + require.NoError(t, err) + plainConnID := plainLease.ConnID() + + type acquireResult struct { + lease *openAIWSConnLease + err error + } + resultCh := make(chan acquireResult, 1) + var done atomic.Bool + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { + betaReq := baseReq + betaReq.Headers = http.Header{"X-Codex-Beta-Features": {"remote_compaction_v2"}} + lease, acquireErr := pool.Acquire(ctx, betaReq) + resultCh <- acquireResult{lease: lease, err: acquireErr} + done.Store(true) + }() + + require.Never(t, done.Load, 50*time.Millisecond, 5*time.Millisecond) + plainLease.Release() + + result := <-resultCh + require.NoError(t, result.err) + require.NotNil(t, result.lease) + require.False(t, result.lease.Reused()) + require.NotEqual(t, plainConnID, result.lease.ConnID()) + result.lease.Release() + require.Equal(t, 2, dialer.DialCount()) +} + +func TestOpenAIWSConnPool_AcquireReplacesIncompatibleIdleWhenMatchingBusy(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := &Account{ID: 131, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + baseReq := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"} + + plainLease, err := pool.Acquire(context.Background(), baseReq) + require.NoError(t, err) + plainConnID := plainLease.ConnID() + plainLease.Release() + + betaReq := baseReq + betaReq.Headers = http.Header{"X-Codex-Beta-Features": {"remote_compaction_v2"}} + busyBetaLease, err := pool.Acquire(context.Background(), betaReq) + require.NoError(t, err) + + secondBetaLease, err := pool.Acquire(context.Background(), betaReq) + require.NoError(t, err) + require.False(t, secondBetaLease.Reused()) + require.NotEqual(t, plainConnID, secondBetaLease.ConnID()) + require.NotEqual(t, busyBetaLease.ConnID(), secondBetaLease.ConnID()) + + secondBetaLease.Release() + busyBetaLease.Release() + require.Equal(t, 3, dialer.DialCount()) +} + func TestOpenAIWSConnPool_AcquireForcePreferredConnUnavailable(t *testing.T) { cfg := &config.Config{} cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 From 51de58b37f662a758dfee4f9cc5aa08c79b88ece Mon Sep 17 00:00:00 2001 From: xiaopixiu2019 Date: Sat, 11 Jul 2026 15:39:32 +0800 Subject: [PATCH 017/104] fix: normalize GPT-5.6 alias in OpenAI OAuth account tests --- .../internal/service/account_test_service.go | 9 ++++-- .../account_test_service_openai_test.go | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b9dae84057..651501ca87 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -582,8 +582,13 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account c.Writer.Header().Set("X-Accel-Buffering", "no") c.Writer.Flush() - // Create OpenAI Responses API payload - payload := createOpenAITestPayload(testModelID, isOAuth) + // Create OpenAI Responses API payload. OAuth accounts use ChatGPT Codex + // upstream and must apply the same model normalization as real forwarding. + upstreamTestModelID := testModelID + if isOAuth { + upstreamTestModelID = normalizeOpenAIModelForUpstream(credentialAccount, testModelID) + } + payload := createOpenAITestPayload(upstreamTestModelID, isOAuth) payloadBytes, _ := json.Marshal(payload) // Send test_start event diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index af28085123..083d882ea7 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -137,6 +137,34 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing. require.Contains(t, recorder.Body.String(), "test_complete") } +func TestAccountTestService_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := newTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{httpUpstream: upstream} + account := &Account{ + ID: 90, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.6", "", "") + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + + body, err := io.ReadAll(upstream.requests[0].Body) + require.NoError(t, err) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String()) +} + func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) { gin.SetMode(gin.TestMode) ctx, recorder := newTestContext() From 94a22b62f7b963b6b671e3fc292ad0af609e6143 Mon Sep 17 00:00:00 2001 From: iMouseWu Date: Sat, 11 Jul 2026 17:13:19 +0800 Subject: [PATCH 018/104] =?UTF-8?q?fix(frontend):=20=E5=B0=8F=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=87=8F=E5=85=B3=E9=97=AD=20DataTable=20=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E5=8C=96=E5=B9=B6=E6=8C=89=E8=A1=8C=E4=B8=BB=E9=94=AE?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=A1=8C=E9=AB=98,=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8=E6=8A=96?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账号列表(可变行高)在 @tanstack/vue-virtual 始终开启虚拟化下,估算行高(72) 远小于真实行高(~157),滚动时每行首次测量触发滚动补偿,表现为"一跳一跳"。 - DataTable 新增 virtualizeThreshold(默认 100):行数不超过阈值时全量渲染, 彻底绕开虚拟器的估算/测量/补偿链路;超过才虚拟化,保留大列表性能 - DataTable 补 getItemKey(与模板 :key 一致的行主键):itemSizeCache 由 index 键 改为行主键,排序/筛选/跨阈值来回都复用正确的已测行高,连带修复 >阈值 档 排序即抖的既存问题 - 虚拟/全量两模式统一为 renderRows,共用同一套行与单元格;measure ref 仅虚拟模式绑定 - useSwipeSelect:全量渲染(虚拟窗口为空)时改用真实 DOM 行按坐标二分定位, 修复关闭虚拟化后滑动多选点错行的隐患;定位逻辑提取为可测的导出函数 - AccountsView:estimate-row-height 72→156 对齐真实行高;virtualize-threshold=50, 常用分页档全量渲染无抖动,pageSize=100 仍虚拟化以约束每行 AccountUsageCell 的首屏请求扇出 - 新增/强化测试:DataTable 虚拟化决策与 getItemKey、useSwipeSelect DOM 兜底定位 经三方评审会(Claude + Codex + 作者)两轮讨论达成共识后落地。 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/common/DataTable.vue | 53 ++++++++++--- .../common/__tests__/DataTable.spec.ts | 59 +++++++++++++++ .../__tests__/useSwipeSelect.spec.ts | 74 +++++++++++++++++++ frontend/src/composables/useSwipeSelect.ts | 42 +++++++++++ frontend/src/views/admin/AccountsView.vue | 3 +- 5 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 frontend/src/composables/__tests__/useSwipeSelect.spec.ts diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index d5fe57145f..e3821c3620 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -154,7 +154,7 @@ - +