Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
79575d1
docs: design Cursor ApplyPatch custom tool round trip
DOUIF Jul 18, 2026
dbbd160
docs: plan Cursor ApplyPatch custom tool round trip
DOUIF Jul 18, 2026
8b935c4
test(translator): reproduce Cursor ApplyPatch round-trip failure
DOUIF Jul 18, 2026
6534b7d
fix(translator): restore request-local custom tool history
DOUIF Jul 18, 2026
9fb5284
fix(translator): stream Codex custom tool calls exactly once
DOUIF Jul 18, 2026
aec7f5d
test(executor): cover custom tools over HTTP and WebSocket
DOUIF Jul 18, 2026
142b91f
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 19, 2026
ed87a13
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 19, 2026
604881a
test(translator): preserve custom tool metadata after array batching
DOUIF Jul 19, 2026
30fb492
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 19, 2026
9ea9a5f
fix(codex): forward Cursor fast service tier
DOUIF Jul 20, 2026
3f5aa43
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 20, 2026
f9fdf09
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 21, 2026
6322f23
chore(security): add outbound audit tooling
DOUIF Jul 21, 2026
6b97e5a
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 21, 2026
c88adcb
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 22, 2026
23c05e4
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 23, 2026
64284dc
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 24, 2026
1c65865
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 25, 2026
1bcae67
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 26, 2026
c2ef187
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 27, 2026
24e97ce
fix(codex): reconcile upstream tool stream semantics
DOUIF Jul 27, 2026
badfb53
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 28, 2026
52044c3
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 29, 2026
1865999
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 30, 2026
6d82f20
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Jul 31, 2026
b622001
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 1, 2026
77fa2d5
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 2, 2026
9adab94
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 3, 2026
7260e76
test(claude): make fingerprint expectations host-independent
DOUIF Aug 3, 2026
cca7b47
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 4, 2026
1a443f4
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 5, 2026
6b4d645
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 6, 2026
7e626a0
Merge remote-tracking branch 'upstream/main' into codex/fix-cursor-ap…
DOUIF Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ temp/*
refs/*
plugins/*
examples/plugin/bin/*
__pycache__/
*.py[cod]

# Storage backends
pgstore/*
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Cursor ApplyPatch Custom Tool Round-Trip Design

## Context

Cursor sends Codex-backed requests through the OpenAI Chat Completions compatibility endpoint. Codex Responses emits ApplyPatch as a `custom_tool_call`, while Chat Completions represents every client-visible tool call with the standard `type: "function"` envelope. The current response translator only recognizes `function_call` events, and the current request translator only restores the custom family when history already uses a non-standard `type: "custom"` envelope. Consequently the custom call is either omitted from the response or returned upstream as a normal function call/output, interrupting the agent loop.

The fix remains entirely request-scoped or response-stream-scoped. It must not use process-global state or carry tool-family state across requests. HTTP and WebSocket executors continue to use the same registered translator.

## Tool Catalog

Build a catalog from the current Chat Completions request's `tools` array. The catalog records:

- every declared function or custom tool name;
- which names uniquely identify custom tools;
- original-to-shortened and shortened-to-original mappings generated across both families; and
- names that are ambiguous because both families claim the same effective name.

Custom declarations remain Responses-compatible top-level `type: "custom"` objects, but their names use the same deterministic shortening rules as function declarations. A history call is restored as custom only when its name uniquely matches a custom declaration in this request. Ambiguous or unknown names remain function calls.

## Follow-Up Request Translation

Chat assistant history normally contains `type: "function"`, `function.name`, and `function.arguments`, even for a client-visible custom call. For each assistant tool-call batch:

1. Resolve the tool name through the request-local catalog.
2. Emit `custom_tool_call` with the bare `function.arguments` string as `input` when the name uniquely identifies a custom declaration.
3. Otherwise emit the existing `function_call` with `arguments` unchanged.
4. Record the resolved family next to the call ID for matching immediately following `role: "tool"` messages.

Explicit legacy `type: "custom"` history remains supported.

Tool outputs match a unique, unconsumed pending call. An explicit call ID must match exactly. A missing output ID may match only when exactly one pending call remains. Duplicate IDs, orphan outputs, duplicate outputs, and otherwise ambiguous outputs are dropped rather than guessed. The matched family selects `custom_tool_call_output` or `function_call_output`.

Missing assistant call IDs receive deterministic request-local synthetic IDs so a uniquely matched output can preserve a valid pair.

## Streaming Response Translation

Replace the single active-call booleans with per-call stream state held in `ConvertCliToOpenAIParams`. Calls are keyed by `item_id`, with `output_index` as a secondary key. Each state records:

- the allocated contiguous Chat tool-call index;
- item ID, output index, call ID, restored name, and family;
- whether the Chat call envelope was announced;
- input already emitted downstream; and
- buffered input observed before enough metadata exists to announce the call.

For `response.output_item.added`, allocate or recover the call state and emit the Chat `tool_calls` envelope when the item is a function or custom call. Both families appear downstream as `type: "function"`; custom free-form input is carried in `function.arguments`.

For argument/input delta events, emit the delta immediately when the call has been announced. If `added` was omitted and name/call ID are not yet known, buffer the data until a later item event supplies metadata.

For argument/input done events, compare the complete value with the input already emitted. Emit only the un-emitted suffix when the complete value has the emitted value as a prefix. Emit the full value when nothing has been emitted. If the values conflict, do not duplicate already emitted bytes.

For `response.output_item.done`, announce a call omitted from `added`, then apply the same suffix fallback using the item's complete `arguments` or `input`.

For `response.completed`, scan output calls and emit any still-missing envelope or input before the terminal chunk. A completed custom call sets `finish_reason` and `native_finish_reason` to `tool_calls`. This covers providers that omit added, delta, done, or output-item-done events.

Sequential and parallel calls remain independent because each call owns its emitted-input and announcement state. Events lacking both identity fields may use the sole active compatible call; when multiple candidates exist, the event is ignored as ambiguous.

## Non-Streaming Response Translation

Treat `custom_tool_call` output items like function calls when building Chat `message.tool_calls`. Preserve `call_id`, restore the original tool name, place bare `input` in `function.arguments`, and return `finish_reason: "tool_calls"` when at least one tool call is present.

## Regression Strategy

Before implementation, add tests that fail on the current `main` behavior and preserve their failing output as root-cause evidence. Coverage includes:

- the complete ApplyPatch transcript from custom declaration through streamed Chat tool call, follow-up `custom_tool_call_output`, and final assistant continuation;
- multi-delta input with exactly-once concatenation;
- done, output-item-done, and completed fallbacks with omitted preceding events;
- sequential custom calls and mixed parallel custom/function calls;
- streaming and non-streaming name restoration after shortening;
- duplicate, missing, unmatched, and ambiguous call IDs;
- standard function-call regression behavior; and
- HTTP and WebSocket executor paths using the registered Chat Completions translator.

After implementation, run formatting, the targeted translator suite, the complete Go suite, and the required server build command.

## Publication and Live Verification

Commit and push `codex/fix-cursor-apply-patch`, then open a draft pull request against upstream `dev`. In the existing Cursor SSH workspace on `tufa15`, save the prior branch and process state, check out the test branch, build and start CPA, and run GPT-5.6 Sol at medium reasoning with a prompt that explicitly requires ApplyPatch to edit a dedicated disposable file.

Success requires the file edit, a returned tool result, continued model reasoning, and a final assistant answer. CPA trace evidence must show the follow-up `custom_tool_call_output`. Afterwards stop the test CPA process, remove the disposable file, restore the prior remote checkout, and verify a clean worktree.
6 changes: 3 additions & 3 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) {
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch())
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64")

lowerReq := newClaudeHeaderTestRequest(t, http.Header{
"User-Agent": []string{"claude-cli/2.1.61 (external, cli)"},
Expand Down Expand Up @@ -653,7 +653,7 @@ func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForCla
})
applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, nil, cfg, nil, true)

assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch())
assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.70.0", "v22.0.0", "MacOS", "arm64")
}

func TestApplyClaudeHeaders_LegacyThirdPartyUsesStableConfiguredOSArch(t *testing.T) {
Expand Down Expand Up @@ -758,7 +758,7 @@ func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testi
t.Fatalf("Execute() error = %v", errExecute)
}

assertClaudeFingerprint(t, seenHeaders, "claude-cli/2.1.220 (external, cli)", "0.94.0", "v26.3.0", helps.MapStainlessOS(), helps.MapStainlessArch())
assertClaudeFingerprint(t, seenHeaders, "claude-cli/2.1.220 (external, cli)", "0.94.0", "v26.3.0", "MacOS", "arm64")
if got := seenHeaders.Get("X-App"); got != "cli" {
t.Fatalf("X-App = %q, want cli", got)
}
Expand Down
152 changes: 152 additions & 0 deletions internal/runtime/executor/codex_custom_tool_translation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package executor

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)

func codexCustomToolChatPayload() []byte {
return []byte(`{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"Apply the patch."}],"tools":[{"type":"custom","name":"ApplyPatch","description":"Apply a freeform patch.","format":{"type":"text"}}]}`)
}

func codexCustomToolStreamEvents() []string {
return []string{
`{"type":"response.created","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-5.6-sol"}}`,
`{"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_apply_patch","name":"ApplyPatch","input":"","status":"in_progress"}}`,
`{"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_1","delta":"abc"}`,
`{"type":"response.custom_tool_call_input.delta","output_index":0,"item_id":"ctc_1","delta":"def"}`,
`{"type":"response.custom_tool_call_input.done","output_index":0,"item_id":"ctc_1","input":"abcdef"}`,
`{"type":"response.output_item.done","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_apply_patch","name":"ApplyPatch","input":"abcdef","status":"completed"}}`,
`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"status":"completed","model":"gpt-5.6-sol","output":[{"id":"ctc_1","type":"custom_tool_call","call_id":"call_apply_patch","name":"ApplyPatch","input":"abcdef","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`,
}
}

func codexCustomToolExecutorOptions() cliproxyexecutor.Options {
return cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai"),
Stream: true,
}
}

func assertCodexCustomToolStream(t *testing.T, result *cliproxyexecutor.StreamResult) {
t.Helper()

var callID string
var name string
var input string
var finishReason string
announcements := 0
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
root := gjson.ParseBytes(chunk.Payload)
for _, toolCall := range root.Get("choices.0.delta.tool_calls").Array() {
if id := toolCall.Get("id"); id.Exists() && id.String() != "" {
callID = id.String()
announcements++
}
if toolName := toolCall.Get("function.name"); toolName.Exists() && toolName.String() != "" {
name = toolName.String()
}
if arguments := toolCall.Get("function.arguments"); arguments.Exists() {
input += arguments.String()
}
}
if reason := root.Get("choices.0.finish_reason"); reason.Exists() && reason.String() != "" {
finishReason = reason.String()
}
}

if callID != "call_apply_patch" || name != "ApplyPatch" {
t.Fatalf("custom tool metadata call_id=%q name=%q", callID, name)
}
if input != "abcdef" {
t.Fatalf("custom tool input = %q, want exactly abcdef", input)
}
if announcements != 1 {
t.Fatalf("custom tool announced %d times, want once", announcements)
}
if finishReason != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls", finishReason)
}
}

func TestCodexExecutorCustomToolUsesChatCompletionsTranslator(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
for _, event := range codexCustomToolStreamEvents() {
_, _ = fmt.Fprintf(w, "data: %s\n\n", event)
}
}))
defer server.Close()

exec := NewCodexExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
auth := &cliproxyauth.Auth{
Provider: "codex",
Attributes: map[string]string{
"api_key": "sk-test",
"base_url": server.URL,
},
}
result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "gpt-5.6-sol",
Payload: codexCustomToolChatPayload(),
}, codexCustomToolExecutorOptions())
if err != nil {
t.Fatalf("ExecuteStream error: %v", err)
}
assertCodexCustomToolStream(t, result)
}

func TestCodexWebsocketsExecutorCustomToolUsesChatCompletionsTranslator(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
if errUpgrade != nil {
t.Errorf("upgrade websocket: %v", errUpgrade)
return
}
defer func() { _ = conn.Close() }()

if _, _, errRead := conn.ReadMessage(); errRead != nil {
t.Errorf("read upstream websocket request: %v", errRead)
return
}
for _, event := range codexCustomToolStreamEvents() {
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
t.Errorf("write upstream websocket event: %v", errWrite)
return
}
}
}))
defer server.Close()

exec := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
auth := &cliproxyauth.Auth{
Provider: "codex",
Attributes: map[string]string{
"api_key": "sk-test",
"base_url": server.URL,
},
}
result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "gpt-5.6-sol",
Payload: codexCustomToolChatPayload(),
}, codexCustomToolExecutorOptions())
if err != nil {
t.Fatalf("ExecuteStream error: %v", err)
}
assertCodexCustomToolStream(t, result)
}
Loading
Loading