mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 02:16:49 +08:00
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:
@@ -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__") {
|
||||
|
||||
Reference in New Issue
Block a user