From 9f9b66ef60ff97b20cd602475665bc854a7b9cf0 Mon Sep 17 00:00:00 2001 From: zhouyayu Date: Fri, 18 Sep 2026 20:00:46 +0800 Subject: [PATCH] feat(responses): bridge custom tools through function calls --- CHANGELOG.md | 4 + internal/api/compat.go | 83 +++++++++++++-- internal/api/compat_test.go | 56 ++++++++++ internal/providers/workbuddy/client_test.go | 22 ++++ internal/translate/compat.go | 109 +++++++++++++------- internal/translate/tools.go | 71 +++++++++++++ internal/translate/tools_test.go | 72 ++++++++++++- 7 files changed, 370 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3817e7..32bce56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English +- Bridge Responses custom tools through function calls, restoring custom output/events and replaying tool results. Format rules are descriptive, not grammar-enforced; custom input events are emitted after argument collection. + - Preserve typed upstream stream errors through the OpenAI, Anthropic, and Responses relays so invalid Devin requests do not falsely cool accounts, while transport interruptions remain retryable - Report Devin cache reads and writes in OpenAI-compatible usage, with prompt totals including all upstream input tokens @@ -19,6 +21,8 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### 中文 +- 通过 function 调用桥接 Responses custom 工具,还原 custom 输出与事件并回放工具结果。格式规则仅作为描述传递,不强制执行语法约束;custom 输入事件在参数收集后发送。 + - OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试 - Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token - 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema diff --git a/internal/api/compat.go b/internal/api/compat.go index 5600c4a..7c0f351 100644 --- a/internal/api/compat.go +++ b/internal/api/compat.go @@ -277,6 +277,32 @@ type proxyToolCall struct { Arguments string } +func proxyToolCallItem(requestID string, callIndex int, call proxyToolCall) map[string]any { + namespace, name, custom := translate.DecodeCustomToolName(call.Name) + if !custom { + return responseFunctionCallItem(requestID, callIndex, call) + } + input := "" + if raw := strings.TrimSpace(call.Arguments); raw != "" { + var payload struct { + Input string `json:"input"` + } + if json.Unmarshal([]byte(raw), &payload) == nil { + input = payload.Input + } else { + input = call.Arguments + } + } + item := map[string]any{ + "id": fmt.Sprintf("ctc_%s_%d", requestID, callIndex), "type": "custom_tool_call", "status": "completed", + "call_id": call.ID, "name": name, "input": input, + } + if namespace != "" { + item["namespace"] = namespace + } + return item +} + func decodeOpenAIToolCalls(raw json.RawMessage) []proxyToolCall { if len(raw) == 0 || string(raw) == "null" { return nil @@ -356,7 +382,7 @@ func responsesOutputItems(requestID, content, reasoning string, toolCalls []prox }) } for callIndex, call := range toolCalls { - items = append(items, responseFunctionCallItem(requestID, callIndex, call)) + items = append(items, proxyToolCallItem(requestID, callIndex, call)) } return items } @@ -806,6 +832,12 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str toolOutputIndexes[index] = nextOutputIndex nextOutputIndex++ item := map[string]any{"id": fmt.Sprintf("fc_%s_%d", requestID, index), "type": "function_call", "status": "in_progress", "call_id": toolCallIDs[index], "name": toolCallNames[index], "arguments": ""} + if namespace, name, custom := translate.DecodeCustomToolName(toolCallNames[index]); custom { + item = map[string]any{"id": fmt.Sprintf("ctc_%s_%d", requestID, index), "type": "custom_tool_call", "status": "in_progress", "call_id": toolCallIDs[index], "name": name, "input": ""} + if namespace != "" { + item["namespace"] = namespace + } + } if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": toolOutputIndexes[index], "item": item}); err != nil { return err } @@ -818,7 +850,13 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str } emitted := toolArgumentLengths[index] if len(accumulated) > emitted { - if err := eventWriter.write("response.function_call_arguments.delta", map[string]any{"type": "response.function_call_arguments.delta", "output_index": toolOutputIndexes[index], "item_id": fmt.Sprintf("fc_%s_%d", requestID, index), "call_id": toolCallIDs[index], "name": toolCallNames[index], "delta": accumulated[emitted:]}); err != nil { + deltaPayload := map[string]any{"type": "response.function_call_arguments.delta", "output_index": toolOutputIndexes[index], "item_id": fmt.Sprintf("fc_%s_%d", requestID, index), "call_id": toolCallIDs[index], "name": toolCallNames[index], "delta": accumulated[emitted:]} + if _, _, custom := translate.DecodeCustomToolName(toolCallNames[index]); custom { + toolArgumentLengths[index] = len(accumulated) + continue + } + eventName, _ := deltaPayload["type"].(string) + if err := eventWriter.write(eventName, deltaPayload); err != nil { return err } toolArgumentLengths[index] = len(accumulated) @@ -854,19 +892,48 @@ func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model str call.ID = fmt.Sprintf("call_%s_%d", requestID, callIndex) } } - item := responseFunctionCallItem(requestID, callIndex, call) + item := proxyToolCallItem(requestID, callIndex, call) outputIndex, ok := toolOutputIndexes[callIndex] if !ok { outputIndex = nextOutputIndex nextOutputIndex++ } - if !toolAnnounced[callIndex] { - if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": map[string]any{"id": item["id"], "type": "function_call", "status": "in_progress", "call_id": call.ID, "name": call.Name, "arguments": ""}}); err != nil { + if namespace, name, custom := translate.DecodeCustomToolName(call.Name); custom { + input := "" + var payload struct { + Input string `json:"input"` + } + if json.Unmarshal([]byte(call.Arguments), &payload) == nil { + input = payload.Input + } else { + input = call.Arguments + } + if !toolAnnounced[callIndex] { + added := map[string]any{"id": item["id"], "type": "custom_tool_call", "status": "in_progress", "call_id": call.ID, "name": name, "input": ""} + if namespace != "" { + added["namespace"] = namespace + } + if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": added}); err != nil { + return stats, err + } + } + if input != "" { + if err := eventWriter.write("response.custom_tool_call_input.delta", map[string]any{"type": "response.custom_tool_call_input.delta", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "delta": input}); err != nil { + return stats, err + } + } + if err := eventWriter.write("response.custom_tool_call_input.done", map[string]any{"type": "response.custom_tool_call_input.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": name, "input": input}); err != nil { + return stats, err + } + } else { + if !toolAnnounced[callIndex] { + if err := eventWriter.write("response.output_item.added", map[string]any{"type": "response.output_item.added", "output_index": outputIndex, "item": map[string]any{"id": item["id"], "type": "function_call", "status": "in_progress", "call_id": call.ID, "name": call.Name, "arguments": ""}}); err != nil { + return stats, err + } + } + if err := eventWriter.write("response.function_call_arguments.done", map[string]any{"type": "response.function_call_arguments.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": call.Name, "arguments": call.Arguments}); err != nil { return stats, err } - } - if err := eventWriter.write("response.function_call_arguments.done", map[string]any{"type": "response.function_call_arguments.done", "output_index": outputIndex, "item_id": item["id"], "call_id": call.ID, "name": call.Name, "arguments": call.Arguments}); err != nil { - return stats, err } if err := eventWriter.write("response.output_item.done", map[string]any{"type": "response.output_item.done", "output_index": outputIndex, "item": item}); err != nil { return stats, err diff --git a/internal/api/compat_test.go b/internal/api/compat_test.go index 43f43ba..4b03017 100644 --- a/internal/api/compat_test.go +++ b/internal/api/compat_test.go @@ -241,6 +241,62 @@ func TestResponsesStreamKeepsDistinctFunctionCallIDs(t *testing.T) { } } +func TestResponsesNonStreamRestoresCustomToolCall(t *testing.T) { + server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "model": "glm-5.2", "choices": []any{map[string]any{"message": map[string]any{ + "content": "", "tool_calls": []any{map[string]any{"id": "call_custom", "type": "function", "function": map[string]string{"name": "__codex_custom__exec", "arguments": `{"input":"patch text"}`}}}, + }, "finish_reason": "tool_calls"}}, + "usage": map[string]any{"prompt_tokens": 8, "completion_tokens": 3}, + }) + }) + defer closeServer() + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"qoder/glm-5.2","input":"hi"}`)) + recorder := httptest.NewRecorder() + server.handleResponses(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response struct { + Output []struct { + Type string `json:"type"` + Name string `json:"name"` + Input string `json:"input"` + } `json:"output"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Output) != 1 || response.Output[0].Type != "custom_tool_call" || response.Output[0].Name != "exec" || response.Output[0].Input != "patch text" { + t.Fatalf("output=%+v", response.Output) + } +} + +func TestResponsesStreamWritesCustomToolCallEvents(t *testing.T) { + server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"function\":{\"name\":\"__codex_custom__exec\",\"arguments\":\"{\\\"input\\\":\\\"patch\"}}]}}]}\n\n") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\" text\\\"}\"}}]}}]}\n\n") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}]}\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + }) + defer closeServer() + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"qoder/glm-5.2","stream":true,"input":"hi"}`)) + recorder := httptest.NewRecorder() + server.handleResponses(recorder, request) + body := recorder.Body.String() + for _, expected := range []string{"event: response.custom_tool_call_input.delta", `"delta":"patch text"`, "event: response.custom_tool_call_input.done", `"type":"custom_tool_call"`, `"name":"exec"`, `"input":"patch text"`} { + if !strings.Contains(body, expected) { + t.Fatalf("missing %q in %s", expected, body) + } + } + if strings.Contains(body, "__codex_custom__") { + t.Fatalf("custom marker leaked into response: %s", body) + } +} + func TestResponsesRejectsStatefulPreviousResponseID(t *testing.T) { server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, _ *http.Request) { t.Fatal("worker should not receive unsupported stateful response request") diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go index 5c3e5ed..64c07ac 100644 --- a/internal/providers/workbuddy/client_test.go +++ b/internal/providers/workbuddy/client_test.go @@ -1287,6 +1287,28 @@ func TestPrepareBodyExpandsNamespaceTools(t *testing.T) { } } +func TestPrepareBodyKeepsEncodedCustomFunctionTools(t *testing.T) { + const encodedName = "functions__codex_custom__apply_patch" + out := PrepareBody([]byte(`{"model":"m","tools":[{"type":"function","function":{"name":"functions__codex_custom__apply_patch","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}],"tool_choice":"functions__codex_custom__apply_patch"}`)) + var body map[string]any + if err := json.Unmarshal(out, &body); err != nil { + t.Fatal(err) + } + tools, _ := body["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools=%v", body["tools"]) + } + tool, _ := tools[0].(map[string]any) + fn, _ := tool["function"].(map[string]any) + if fn["name"] != encodedName { + t.Fatalf("encoded custom function name changed: %v", fn["name"]) + } + choice, _ := body["tool_choice"].(string) + if choice != encodedName { + t.Fatalf("encoded custom tool choice changed: %v", body["tool_choice"]) + } +} + func TestCatalogHeadersUseDesktopUAOnlyForGlobal(t *testing.T) { global := http.Header{} SetCatalogHeaders(global, Credential{AccessToken: "at", UID: "u1", Domain: "www.workbuddy.ai"}) diff --git a/internal/translate/compat.go b/internal/translate/compat.go index 1c016ac..09bb4bc 100644 --- a/internal/translate/compat.go +++ b/internal/translate/compat.go @@ -119,21 +119,21 @@ func TranslateAnthropicMessages(request AnthropicMessagesRequest) (ChatRequest, if err != nil { return ChatRequest{}, err } -chat.Tools = tools - toolChoice, err := translateAnthropicToolChoice(request.ToolChoice) - if err != nil { - return ChatRequest{}, err - } - chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) - chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice) - if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { - chat.ReasoningEffort = effort - } - if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { - return ChatRequest{}, err - } - return chat, nil + chat.Tools = tools + toolChoice, err := translateAnthropicToolChoice(request.ToolChoice) + if err != nil { + return ChatRequest{}, err + } + chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) + chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice) + if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { + chat.ReasoningEffort = effort } + if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { + return ChatRequest{}, err + } + return chat, nil +} func TranslateResponses(request ResponsesRequest) (ChatRequest, error) { if strings.TrimSpace(request.PreviousID) != "" || !emptyJSON(request.Conversation) { @@ -175,24 +175,24 @@ func TranslateResponses(request ResponsesRequest) (ChatRequest, error) { if err != nil { return ChatRequest{}, err } -chat.Tools = tools - toolChoice, err := translateResponsesToolChoice(request.ToolChoice) - if err != nil { - return ChatRequest{}, err - } - // Codex Desktop compact / recovery turns can keep a tool_choice while - // tools normalize to empty (hosted shells dropped, etc). Drop the - // orphan choice instead of failing the whole turn. - chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) - chat.ResponseFormat, err = translateResponsesTextFormat(request.Text) - if err != nil { - return ChatRequest{}, err - } - if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { - return ChatRequest{}, err - } - return chat, nil + chat.Tools = tools + toolChoice, err := translateResponsesToolChoice(request.ToolChoice) + if err != nil { + return ChatRequest{}, err } + // Codex Desktop compact / recovery turns can keep a tool_choice while + // tools normalize to empty (hosted shells dropped, etc). Drop the + // orphan choice instead of failing the whole turn. + chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) + chat.ResponseFormat, err = translateResponsesTextFormat(request.Text) + if err != nil { + return ChatRequest{}, err + } + if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { + return ChatRequest{}, err + } + return chat, nil +} func anthropicMessageParts(raw json.RawMessage) (any, []compatibilityToolCall, []compatibilityToolResult, error) { if rawText, ok := rawJSONString(raw); ok { @@ -465,6 +465,20 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) { if len(images) > 0 { messages = append(messages, ChatMessage{Role: "user", Content: images}) } + case "custom_tool_call_output": + callID := rawMapString(source, "call_id") + if callID == "" { + return nil, fmt.Errorf("input[%d].call_id required", itemIndex) + } + content, images, err := responsesFunctionCallOutput(rawMapJSON(source, "output")) + if err != nil { + return nil, fmt.Errorf("input[%d].output: %w", itemIndex, err) + } + flushPendingReasoning() + messages = append(messages, ChatMessage{Role: "tool", ToolCallID: callID, Content: content}) + if len(images) > 0 { + messages = append(messages, ChatMessage{Role: "user", Content: images}) + } case "input_file": return nil, fmt.Errorf("input[%d] file inputs are not supported by the Qoder upstream", itemIndex) case "function_call": @@ -484,6 +498,19 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) { return nil, fmt.Errorf("input[%d].arguments must be valid JSON", itemIndex) } appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: name, Arguments: arguments}})}) + case "custom_tool_call": + name := rawMapString(source, "name") + namespace := rawMapString(source, "namespace") + callID := firstRawMapString(source, "call_id", "id") + if name == "" || callID == "" { + return nil, fmt.Errorf("input[%d] custom_tool_call requires name and call_id", itemIndex) + } + input := rawMapString(source, "input") + arguments, err := json.Marshal(map[string]string{"input": input}) + if err != nil { + return nil, fmt.Errorf("input[%d] custom_tool_call input: %w", itemIndex, err) + } + appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: EncodeCustomToolName(namespace, name), Arguments: arguments}})}) case "reasoning": // Codex/Desktop replays prior Responses reasoning items. WorkBuddy // thinking mode requires that text back on the assistant turn as @@ -579,14 +606,22 @@ func translateResponsesToolChoice(raw json.RawMessage) (json.RawMessage, error) if err := json.Unmarshal(raw, &source); err != nil { return nil, fmt.Errorf("tool_choice must be a string or an object") } - if rawMapString(source, "type") != "function" { + switch rawMapString(source, "type") { + case "function": + name := rawMapString(source, "name") + if name == "" { + return nil, fmt.Errorf("tool_choice.name required") + } + return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}}) + case "custom": + name := rawMapString(source, "name") + if name == "" { + return nil, fmt.Errorf("tool_choice.name required") + } + return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": EncodeCustomToolName(rawMapString(source, "namespace"), name)}}) + default: return nil, fmt.Errorf("tool_choice type %q is not supported", rawMapString(source, "type")) } - name := rawMapString(source, "name") - if name == "" { - return nil, fmt.Errorf("tool_choice.name required") - } - return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}}) } func responseReasoningEffort(raw json.RawMessage) json.RawMessage { diff --git a/internal/translate/tools.go b/internal/translate/tools.go index ddaddd8..b09d825 100644 --- a/internal/translate/tools.go +++ b/internal/translate/tools.go @@ -8,6 +8,60 @@ import ( const defaultToolParameters = `{"type":"object","properties":{}}` +const ( + customToolMarker = "__codex_custom__" + customToolParameters = `{"type":"object","properties":{"input":{"type":"string","description":"Raw freeform input for the custom tool."}},"required":["input"],"additionalProperties":false}` +) + +// EncodeCustomToolName maps a Codex custom/freeform tool onto an upstream +// function name. The marker keeps the round trip reversible without changing +// the namespace/name identity carried by CustomToolCall. +func EncodeCustomToolName(namespace, name string) string { + return customToolName(namespace, name) +} + +// DecodeCustomToolName reverses EncodeCustomToolName. It returns ok=false for +// ordinary function tools. +func DecodeCustomToolName(upstreamName string) (namespace, name string, ok bool) { + upstreamName = strings.TrimSpace(upstreamName) + index := strings.Index(upstreamName, customToolMarker) + if index < 0 { + return "", "", false + } + name = strings.TrimSpace(upstreamName[index+len(customToolMarker):]) + if name == "" { + return "", "", false + } + return strings.TrimSpace(upstreamName[:index]), name, true +} + +func customToolName(namespace, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return customToolMarker + name + } + return strings.TrimRight(namespace, "_") + customToolMarker + name +} + +func customToolDescription(description string, format json.RawMessage) string { + description = strings.TrimSpace(description) + if len(format) == 0 || string(format) == "null" { + return description + } + formatted := strings.TrimSpace(string(format)) + if formatted == "" { + return description + } + if description == "" { + return "Custom tool input format:\n" + formatted + } + return description + "\n\nCustom tool input format:\n" + formatted +} + // NormalizeOpenAITools expands Codex/Desktop namespace wrappers into plain // OpenAI function tools and drops hosted shells such as mcp / web_search. // Nested tools may be Responses-flat or Chat Completions shaped. Short nested @@ -55,6 +109,9 @@ func NormalizeOpenAITools(raw json.RawMessage) (json.RawMessage, error) { for _, nested := range expandNamespaceToolItems(item, strings.TrimSpace(rawMapString(probe, "name"))) { appendTool(nested.name, nested.description, nested.parameters) } + case "custom": + name := customToolName("", rawMapString(probe, "name")) + appendTool(name, customToolDescription(rawMapString(probe, "description"), rawMapJSON(probe, "format")), json.RawMessage(customToolParameters)) case "mcp", "web_search", "web_search_preview": continue case "function", "": @@ -74,6 +131,7 @@ type normalizedTool struct { name string description string parameters json.RawMessage + custom bool } func expandNamespaceToolItems(raw json.RawMessage, namespace string) []normalizedTool { @@ -90,6 +148,19 @@ func expandNamespaceToolItems(raw json.RawMessage, namespace string) []normalize continue } typ := strings.ToLower(strings.TrimSpace(rawMapString(probe, "type"))) + if typ == "custom" { + name := customToolName(namespace, rawMapString(probe, "name")) + if name == "" { + continue + } + out = append(out, normalizedTool{ + name: name, + description: customToolDescription(rawMapString(probe, "description"), rawMapJSON(probe, "format")), + parameters: json.RawMessage(customToolParameters), + custom: true, + }) + continue + } if typ != "" && typ != "function" { continue } diff --git a/internal/translate/tools_test.go b/internal/translate/tools_test.go index 7ef6178..86a537a 100644 --- a/internal/translate/tools_test.go +++ b/internal/translate/tools_test.go @@ -2,6 +2,7 @@ package translate import ( "encoding/json" + "strings" "testing" ) @@ -16,7 +17,7 @@ func TestNormalizeOpenAIToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) {"type":"web_search"}, {"type":"web_search_preview"}, {"type":"namespace","name":"mcp__empty","tools":[]}, - {"type":"custom","name":"ignored"} + {"type":"custom","name":"exec","description":"run code","format":{"type":"grammar","syntax":"lark","definition":"start: \"x\""}} ]`) got, err := NormalizeOpenAITools(raw) if err != nil { @@ -33,7 +34,7 @@ func TestNormalizeOpenAIToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) if err := json.Unmarshal(got, &tools); err != nil { t.Fatal(err) } - want := []string{"lookup", "mcp__computer-use__left_click", "mcp__computer-use__type"} + want := []string{"lookup", "mcp__computer-use__left_click", "mcp__computer-use__type", "__codex_custom__exec"} if len(tools) != len(want) { t.Fatalf("tools=%v want %v", toolNames(tools), want) } @@ -47,6 +48,73 @@ func TestNormalizeOpenAIToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) } } +func TestNormalizeOpenAIToolsExpandsNamespacedCustomTools(t *testing.T) { + got, err := NormalizeOpenAITools(json.RawMessage(`[ + {"type":"namespace","name":"functions","tools":[ + {"type":"custom","name":"apply_patch","description":"Apply a patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}} + ]} + ]`)) + if err != nil { + t.Fatal(err) + } + var tools []struct { + Function struct { + Name string `json:"name"` + Parameters json.RawMessage `json:"parameters"` + Description string `json:"description"` + } `json:"function"` + } + if err := json.Unmarshal(got, &tools); err != nil { + t.Fatal(err) + } + if len(tools) != 1 || tools[0].Function.Name != "functions__codex_custom__apply_patch" { + t.Fatalf("tools=%s", got) + } + if !strings.Contains(tools[0].Function.Description, "start: patch") { + t.Fatalf("description=%q", tools[0].Function.Description) + } + namespace, name, ok := DecodeCustomToolName(tools[0].Function.Name) + if !ok || namespace != "functions" || name != "apply_patch" { + t.Fatalf("decoded=(%q,%q,%v)", namespace, name, ok) + } +} + +func TestTranslateResponsesAcceptsCustomToolChoice(t *testing.T) { + chat, err := TranslateResponses(ResponsesRequest{ + Model: "workbuddy/glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: json.RawMessage(`[{"type":"custom","name":"exec","format":{"type":"grammar","syntax":"lark","definition":"start: \"x\""}}]`), + ToolChoice: json.RawMessage(`{"type":"custom","name":"exec"}`), + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(chat.Tools), `"name":"__codex_custom__exec"`) { + t.Fatalf("tools=%s", chat.Tools) + } + if !strings.Contains(string(chat.ToolChoice), `"__codex_custom__exec"`) { + t.Fatalf("tool_choice=%s", chat.ToolChoice) + } +} + +func TestTranslateResponsesDropsAutoChoiceWhenToolsNormalizeEmpty(t *testing.T) { + chat, err := TranslateResponses(ResponsesRequest{ + Model: "workbuddy/glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: json.RawMessage(`[{"type":"mcp","server_label":"codex_apps"}]`), + ToolChoice: json.RawMessage(`"auto"`), + }) + if err != nil { + t.Fatal(err) + } + if len(chat.Tools) != 0 || len(chat.ToolChoice) != 0 { + t.Fatalf("tools=%q choice=%q", chat.Tools, chat.ToolChoice) + } + if err := ValidateChatRequest(&chat); err != nil { + t.Fatal(err) + } +} + func TestTranslateResponsesAcceptsNamespaceTools(t *testing.T) { chat, err := TranslateResponses(ResponsesRequest{ Model: "workbuddy/glm-5.2",