feat(translator): 支持 Codex additional_tools 工具下发与 custom 工具历史回放

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
This commit is contained in:
辰ing
2026-07-12 20:39:55 +08:00
committed by Luis Pater
parent 07455ecba7
commit bd7cc64732
2 changed files with 135 additions and 10 deletions
@@ -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() {
@@ -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__") {