From bd7cc64732faba4c0c3ae5015e95849c5f56c2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=B0ing?= Date: Sat, 11 Jul 2026 18:07:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(translator):=20=E6=94=AF=E6=8C=81=20Codex?= =?UTF-8?q?=20additional=5Ftools=20=E5=B7=A5=E5=85=B7=E4=B8=8B=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=20custom=20=E5=B7=A5=E5=85=B7=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Desktop(Responses Lite,请求头 X-Openai-Internal-Codex-Responses-Lite) 的请求顶层 tools 为 null:工具定义经 input 中的 additional_tools item 下发,历史工具调用为 custom_tool_call / custom_tool_call_output item。 原转换仅识别顶层 tools 与 message/reasoning/function_call/ function_call_output,导致发往 openai-compatibility 上游的请求既无 工具定义也无工具调用历史,模型据此答复"没有可用工具"。 请求方向: - 工具收集合并顶层 tools 与所有 additional_tools item(function/ namespace/custom 共用同一转换) - custom(freeform)工具定义映射为带 {"input": string} 参数的 function 工具,与 Codex function 形态 apply_patch 的形状一致 - custom_tool_call 回放为 assistant tool_calls,原始 input 包装为 {"input": ...} arguments;custom_tool_call_output 回放为 tool 消息,content-parts 数组拍平为文本,并纳入 outputCallIDs 预扫描 以维持工具消息相邻性 同时引入响应方向所需的 responsesCustomToolNames / unwrapCustomToolInput 辅助函数。 Ref #4219 --- .../openai_openai-responses_request.go | 61 +++++++++++--- .../openai_openai-responses_tools.go | 84 +++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index c071076d..c5e76dc4 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -60,7 +60,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu inputItems := input.Array() outputCallIDs := make(map[string]struct{}) for _, item := range inputItems { - if item.Get("type").String() != "function_call_output" { + itemType := item.Get("type").String() + if itemType != "function_call_output" && itemType != "custom_tool_call_output" { continue } callID := strings.TrimSpace(item.Get("call_id").String()) @@ -138,7 +139,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if itemType == "" && item.Get("role").String() != "" { itemType = "message" } - if itemType != "function_call" { + if itemType != "function_call" && itemType != "custom_tool_call" { flushPendingToolCalls() } @@ -257,6 +258,33 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 { flushDeferredMessages() } + + case "custom_tool_call": + // Codex freeform tool call replay: wrap the raw input so it + // matches the {"input": string} function shape used when + // converting custom tool definitions. + toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) + toolCall, _ = sjson.SetBytes(toolCall, "id", item.Get("call_id").String()) + toolCall, _ = sjson.SetBytes(toolCall, "function.name", item.Get("name").String()) + wrappedArgs, _ := sjson.SetBytes([]byte(`{"input":""}`), "input", item.Get("input").String()) + toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", string(wrappedArgs)) + pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value()) + if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { + pendingToolCallIDs = append(pendingToolCallIDs, callID) + } + + case "custom_tool_call_output": + toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + callID := strings.TrimSpace(item.Get("call_id").String()) + toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID) + toolMessage, _ = sjson.SetBytes(toolMessage, "content", responsesToolOutputText(item.Get("output"))) + out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage) + if callID != "" { + delete(awaitingToolOutputs, callID) + } + if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 { + flushDeferredMessages() + } } } @@ -270,20 +298,33 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu out, _ = sjson.SetRawBytes(out, "messages.-1", msg) } - // Convert tools from responses format to chat completions format - if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { - var chatCompletionsTools []interface{} - + // Convert tools from responses format to chat completions format. + // Codex Desktop (Responses Lite) delivers tool definitions through an + // "additional_tools" input item instead of the top-level "tools" field, + // so merge both sources. + var chatCompletionsTools []interface{} + appendChatTools := func(tools gjson.Result) { + if !tools.Exists() || !tools.IsArray() { + return + } tools.ForEach(func(_, tool gjson.Result) bool { for _, chatTool := range convertResponsesToolToOpenAIChatTools(tool) { chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value()) } return true }) - - if len(chatCompletionsTools) > 0 { - out, _ = sjson.SetBytes(out, "tools", chatCompletionsTools) - } + } + appendChatTools(root.Get("tools")) + if input := root.Get("input"); input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "additional_tools" { + appendChatTools(item.Get("tools")) + } + return true + }) + } + if len(chatCompletionsTools) > 0 { + out, _ = sjson.SetBytes(out, "tools", chatCompletionsTools) } if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index a382b4a2..5dd8c6e5 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -16,12 +16,32 @@ func convertResponsesToolToOpenAIChatTools(tool gjson.Result) [][]byte { } case "namespace": return convertResponsesNamespaceToolToOpenAIChat(tool) + case "custom": + if tJSON, ok := convertResponsesCustomToolToOpenAIChat(tool); ok { + return [][]byte{tJSON} + } default: return nil } return nil } +// convertResponsesCustomToolToOpenAIChat maps a Responses freeform ("custom") +// tool onto a Chat Completions function tool with a single freeform "input" +// string, mirroring the function-based shape Codex uses for apply_patch. +func convertResponsesCustomToolToOpenAIChat(tool gjson.Result) ([]byte, bool) { + name := responsesToolName(tool) + if name == "" { + return nil, false + } + chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}`) + chatTool, _ = sjson.SetBytes(chatTool, "function.name", name) + if description := responsesToolDescription(tool); description != "" { + chatTool, _ = sjson.SetBytes(chatTool, "function.description", description) + } + return chatTool, true +} + func convertResponsesNamespaceToolToOpenAIChat(tool gjson.Result) [][]byte { namespaceName := strings.TrimSpace(tool.Get("name").String()) children := tool.Get("tools") @@ -90,6 +110,70 @@ func responsesToolParameters(tool gjson.Result) gjson.Result { return gjson.Result{} } +// responsesToolOutputText flattens a tool output value that may be a plain +// string or an array of content parts ({"type":"input_text","text":...}) into +// a single text payload for a Chat Completions tool message. +func responsesToolOutputText(output gjson.Result) string { + if output.Type == gjson.String { + return output.String() + } + if output.IsArray() { + var b strings.Builder + output.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text"); text.Exists() { + b.WriteString(text.String()) + } + return true + }) + return b.String() + } + if output.Exists() { + return output.Raw + } + return "" +} + +// responsesCustomToolNames collects the names of freeform ("custom") tools +// declared in the original Responses request, both in the top-level "tools" +// field and in Codex Desktop "additional_tools" input items. +func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { + names := make(map[string]struct{}) + collect := func(tools gjson.Result) { + if !tools.Exists() || !tools.IsArray() { + return + } + tools.ForEach(func(_, tool gjson.Result) bool { + if strings.TrimSpace(tool.Get("type").String()) == "custom" { + if name := responsesToolName(tool); name != "" { + names[name] = struct{}{} + } + } + return true + }) + } + root := gjson.ParseBytes(requestRawJSON) + collect(root.Get("tools")) + if input := root.Get("input"); input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "additional_tools" { + collect(item.Get("tools")) + } + return true + }) + } + return names +} + +// unwrapCustomToolInput extracts the freeform input from the {"input": "..."} +// function-call arguments produced for a converted custom tool; it falls back +// to the raw arguments when the wrapper is absent. +func unwrapCustomToolInput(arguments string) string { + if v := gjson.Get(arguments, "input"); v.Exists() && v.Type == gjson.String { + return v.String() + } + return arguments +} + func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { childName = strings.TrimSpace(childName) if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {