From 65cf04165876688b2e11b364d27a0211de5257c4 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:40:57 +0800
Subject: [PATCH 01/13] fix(cursor): print login URL on stdout regardless of
logging-to-file
When logging-to-file is true, ConfigureLogOutput redirects all logrus
output to ~/.cli-proxy-api/logs/main.log. The cursor login URL was
therefore hidden in the log file and the program appeared to hang
with no output on the terminal.
Switch the four user-facing prompts (Starting / URL / Waiting /
Success) in sdk/auth/cursor.go from log.* to fmt.Println/Printf so
they always reach stdout, regardless of the logging-to-file setting.
log.Warnf for the browser-open failure is kept on logrus since it is
diagnostic, not user-facing.
Fixes kaitranntt/CLIProxyAPIPlus#122
---
sdk/auth/cursor.go | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go
index 792eb6063..04d074a9a 100644
--- a/sdk/auth/cursor.go
+++ b/sdk/auth/cursor.go
@@ -46,9 +46,14 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
return nil, fmt.Errorf("cursor: failed to generate auth params: %w", err)
}
- // Display the login URL
- log.Info("Starting Cursor authentication...")
- log.Infof("Please visit this URL to log in: %s", authParams.LoginURL)
+ // Display the login URL. These four prompts are user-facing CLI output
+ // for a one-shot login command, so they go to stdout via fmt (bypassing
+ // logrus) -- otherwise logging-to-file:true would hide them inside
+ // ~/.cli-proxy-api/logs/main.log and the user would see the program
+ // appear to hang (issue #122). The browser-open warning stays on logrus
+ // because it is a diagnostic, not a prompt.
+ fmt.Println("Starting Cursor authentication...")
+ fmt.Printf("Please visit this URL to log in: %s\n", authParams.LoginURL)
// Try to open the browser automatically
if !opts.NoBrowser {
@@ -59,7 +64,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
}
}
- log.Info("Waiting for Cursor authorization...")
+ fmt.Println("Waiting for Cursor authorization...")
// Poll for the auth result
tokens, err := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier)
@@ -73,7 +78,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
sub := cursorauth.ParseJWTSub(tokens.AccessToken)
subHash := cursorauth.SubToShortHash(sub)
- log.Info("Cursor authentication successful!")
+ fmt.Println("Cursor authentication successful!")
metadata := map[string]any{
"type": "cursor",
From 69d09fe597f3996e78e3189b3d2a4d5c29fc9b27 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:42:47 +0800
Subject: [PATCH 02/13] fix(cursor): cache last-good model list and refresh
stale fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FetchCursorModels previously returned GetCursorFallbackModels() on every
failure (DNS timeout, non-2xx, empty parse, etc.). The hardcoded list was
also months out of date — it contained composer-2, claude-3.5-sonnet,
gpt-4o, cursor-small, gemini-2.5-pro — none of which Cursor serves today.
Combined effect: a single transient GetUsableModels failure (observed at
2026-06-27 21:53:33, dns i/o timeout to api2.cursor.sh) caused the
registry reconcile to overwrite the live 119-model list with the stale
6-model fallback, permanently removing composer-2.5 and friends from
the registry until the auth file was reloaded. User calls to
cursor/composer-2.5 then returned an instant 503 with no executor trace.
Two changes:
1. Add a package-level cache keyed by auth.ID. On a successful live
fetch, store the result. On failure, return the cached list when
available; fall back to GetCursorFallbackModels only when no prior
fetch has populated the cache. A transient blip can no longer
collapse the registry.
2. Refresh GetCursorFallbackModels to current Cursor model ids
(composer-2.5, composer-2.5-fast, gpt-5.3-codex, gpt-5.2,
gpt-5.5-high, gpt-5.4-high, claude-opus-4-8-thinking-high,
claude-opus-4-7-thinking-high, claude-4.5-sonnet, gemini-3.1-pro).
Verified live against api2.cursor.sh on 2026-06-27.
Adds cursor_models_cache_test.go covering both the cache-or-fallback
preference and the freshness of the hardcoded list.
---
internal/runtime/executor/cursor_executor.go | 78 ++++++++++---
.../executor/cursor_models_cache_test.go | 106 ++++++++++++++++++
2 files changed, 170 insertions(+), 14 deletions(-)
create mode 100644 internal/runtime/executor/cursor_models_cache_test.go
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index eb1748fd1..0ffb0c350 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -1479,11 +1479,51 @@ func decodeMcpArgsToJSON(args map[string][]byte) string {
// --- Model Discovery ---
-// FetchCursorModels retrieves available models from Cursor's API.
+// cursorModelsCache stores the last successful model list per auth so a
+// transient GetUsableModels failure does not collapse the model registry to
+// the hardcoded fallback (which can drop models the user actually calls,
+// e.g. composer-2.5). Keyed by auth.ID (the auth file name).
+var (
+ cursorModelsCacheMu sync.RWMutex
+ cursorModelsCache = make(map[string][]*registry.ModelInfo)
+)
+
+// cursorModelsOrFallback returns the cached model list for authID when one
+// exists, otherwise the hardcoded fallback. The fallback is only ever used
+// when no prior successful fetch has populated the cache for this auth.
+func cursorModelsOrFallback(authID string) []*registry.ModelInfo {
+ if authID != "" {
+ cursorModelsCacheMu.RLock()
+ cached, ok := cursorModelsCache[authID]
+ cursorModelsCacheMu.RUnlock()
+ if ok && len(cached) > 0 {
+ return cached
+ }
+ }
+ return GetCursorFallbackModels()
+}
+
+// cacheCursorModels records a successful model fetch for future fallback use.
+func cacheCursorModels(authID string, models []*registry.ModelInfo) {
+ if authID == "" || len(models) == 0 {
+ return
+ }
+ cursorModelsCacheMu.Lock()
+ cursorModelsCache[authID] = models
+ cursorModelsCacheMu.Unlock()
+}
+
+// FetchCursorModels retrieves available models from Cursor's API. On failure,
+// returns the last successful model list cached for this auth when available;
+// only falls back to GetCursorFallbackModels() when no cached list exists.
+// This prevents a transient network blip from permanently shrinking the model
+// registry to stale hardcoded names.
func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
+ authID := auth.ID
+
accessToken := cursorAccessToken(auth)
if accessToken == "" {
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
@@ -1497,7 +1537,7 @@ func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config
cursorAPIURL+cursorModelsPath, bytes.NewReader(emptyReq))
if err != nil {
log.Debugf("cursor: failed to create models request: %v", err)
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
h2Req.Header.Set("Content-Type", "application/proto")
@@ -1511,24 +1551,26 @@ func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config
resp, err := client.Do(h2Req)
if err != nil {
log.Debugf("cursor: models request failed: %v", err)
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Debugf("cursor: models request returned status %d", resp.StatusCode)
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
models := parseModelsResponse(body)
if len(models) == 0 {
- return GetCursorFallbackModels()
+ return cursorModelsOrFallback(authID)
}
+
+ cacheCursorModels(authID, models)
return models
}
@@ -1655,15 +1697,23 @@ func parseModelEntry(data []byte) *registry.ModelInfo {
return info
}
-// GetCursorFallbackModels returns hardcoded fallback models.
+// GetCursorFallbackModels returns hardcoded fallback models used when no
+// cached live-fetch result is available. Kept current with Cursor's actual
+// catalog so a transient GetUsableModels failure does not remove models the
+// user can still call (notably composer-2.5). Update when Cursor retires or
+// renames any of these ids; verified live against api2.cursor.sh on 2026-06-27.
func GetCursorFallbackModels() []*registry.ModelInfo {
return []*registry.ModelInfo{
- {ID: "composer-2", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
- {ID: "claude-4-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 4 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
- {ID: "claude-3.5-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 3.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192},
- {ID: "gpt-4o", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-4o", ContextLength: 128000, MaxCompletionTokens: 16384},
- {ID: "cursor-small", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Cursor Small", ContextLength: 200000, MaxCompletionTokens: 64000},
- {ID: "gemini-2.5-pro", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Gemini 2.5 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
+ {ID: "composer-2.5", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
+ {ID: "composer-2.5-fast", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5 Fast", ContextLength: 200000, MaxCompletionTokens: 64000},
+ {ID: "gpt-5.3-codex", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Codex 5.3", ContextLength: 256000, MaxCompletionTokens: 32768},
+ {ID: "gpt-5.2", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.2", ContextLength: 256000, MaxCompletionTokens: 32768},
+ {ID: "gpt-5.5-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.5 High", ContextLength: 256000, MaxCompletionTokens: 32768},
+ {ID: "gpt-5.4-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-5.4 High", ContextLength: 256000, MaxCompletionTokens: 32768},
+ {ID: "claude-opus-4-8-thinking-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude Opus 4.8 Thinking High", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
+ {ID: "claude-opus-4-7-thinking-high", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude Opus 4.7 Thinking High", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
+ {ID: "claude-4.5-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 4.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192},
+ {ID: "gemini-3.1-pro", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Gemini 3.1 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}},
}
}
diff --git a/internal/runtime/executor/cursor_models_cache_test.go b/internal/runtime/executor/cursor_models_cache_test.go
new file mode 100644
index 000000000..32f2b31d7
--- /dev/null
+++ b/internal/runtime/executor/cursor_models_cache_test.go
@@ -0,0 +1,106 @@
+package executor
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+)
+
+// withCursorModelsCache swaps the package-level cache for the duration of a test
+// and restores the previous state on cleanup.
+func withCursorModelsCache(t *testing.T, seed map[string][]*registry.ModelInfo) {
+ t.Helper()
+ cursorModelsCacheMu.Lock()
+ prev := cursorModelsCache
+ cursorModelsCache = make(map[string][]*registry.ModelInfo, len(seed))
+ for k, v := range seed {
+ // copy to avoid test mutation leaking across runs
+ dup := make([]*registry.ModelInfo, len(v))
+ copy(dup, v)
+ cursorModelsCache[k] = dup
+ }
+ cursorModelsCacheMu.Unlock()
+ t.Cleanup(func() {
+ cursorModelsCacheMu.Lock()
+ cursorModelsCache = prev
+ cursorModelsCacheMu.Unlock()
+ })
+}
+
+// TestCursorModelsOrFallback_PrefersCacheOverHardcoded ensures that when a
+// previous successful fetch cached models for an auth, a subsequent fetch
+// failure returns the cached models instead of the hardcoded fallback.
+// This prevents the live->fallback->live churn that removes working models
+// (e.g. composer-2.5) from the registry after a transient network blip.
+func TestCursorModelsOrFallback_PrefersCacheOverHardcoded(t *testing.T) {
+ cached := []*registry.ModelInfo{
+ {ID: "composer-2.5", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5"},
+ }
+ withCursorModelsCache(t, map[string][]*registry.ModelInfo{
+ "auth-with-cache": cached,
+ })
+
+ got := cursorModelsOrFallback("auth-with-cache")
+ if len(got) != 1 || got[0].ID != "composer-2.5" {
+ t.Fatalf("expected cached [composer-2.5], got %+v", got)
+ }
+
+ // Unknown auth id with no cache entry must fall through to the hardcoded list.
+ fb := cursorModelsOrFallback("never-seen-auth")
+ if len(fb) == 0 {
+ t.Fatal("expected non-empty hardcoded fallback for unknown auth")
+ }
+}
+
+// TestGetCursorFallbackModels_IsCurrent guards against the hardcoded list
+// drifting to stale model ids (e.g. claude-3.5-sonnet, gpt-4o) and against
+// it losing the models users actually call (e.g. composer-2.5).
+func TestGetCursorFallbackModels_IsCurrent(t *testing.T) {
+ fb := GetCursorFallbackModels()
+ if len(fb) == 0 {
+ t.Fatal("hardcoded fallback must not be empty")
+ }
+
+ ids := make(map[string]bool, len(fb))
+ for _, m := range fb {
+ ids[m.ID] = true
+ }
+
+ // Must contain: the model the user actually calls, plus a representative
+ // slice of current Cursor model families.
+ mustHave := []string{
+ "composer-2.5",
+ "composer-2.5-fast",
+ "gpt-5.3-codex",
+ "gpt-5.2",
+ "claude-opus-4-8-thinking-high",
+ "gemini-3.1-pro",
+ }
+ for _, id := range mustHave {
+ if !ids[id] {
+ t.Errorf("hardcoded fallback missing required current model %q; have %v", id, mapKeys(ids))
+ }
+ }
+
+ // Must NOT contain: model ids Cursor no longer serves (verified by a live
+ // GetUsableModels call returning 0 occurrences of these names).
+ mustNotHave := []string{
+ "claude-3.5-sonnet",
+ "gpt-4o",
+ "cursor-small",
+ "gemini-2.5-pro",
+ }
+ for _, id := range mustNotHave {
+ if ids[id] {
+ t.Errorf("hardcoded fallback still contains stale model %q (Cursor no longer serves it)", id)
+ }
+ }
+}
+
+func mapKeys(m map[string]bool) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
From 59b83e020d93ecda05c3faeb3e652e48bf0f0f91 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Sun, 28 Jun 2026 00:43:09 +0800
Subject: [PATCH 03/13] fix(cursor): honor oauth-model-alias by sending
resolved model to Run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cursor executor used parsed.Model (the model name extracted from the
translated request payload) when building the upstream Run request to
api2.cursor.sh. For OpenAI-source requests the translator is skipped
entirely (the from!=openai guard at line 293), so parsed.Model always
equalled whatever the client sent — including an alias name like
"cursor/composer-2.5" defined under oauth-model-alias in config.yaml.
The conductor does resolve the alias correctly: applyOAuthModelAlias in
sdk/cliproxy/auth/conductor.go sets the upstream model name on
req.Model. But because parsed.Model still held the alias, the protobuf
Run request was encoded with the alias as ModelId. Cursor's Run
endpoint does not know "cursor/composer-2.5" (only "composer-2.5")
and returned Connect error not_found. Calling the upstream model name
directly worked because no alias was involved and the payload already
held the real name.
Fix: give buildRunRequestParams a third modelOverride parameter. When
non-empty it is used as ModelId; otherwise parsed.Model is used so
existing call sites behave unchanged. All four call sites (Execute +
three sites in ExecuteStream's checkpoint branches) pass req.Model.
parsed.Model is untouched, so the OpenAI response still echoes the
client-facing alias name as before.
Adds cursor_executor_buildrequest_test.go covering override wins over
parsed.Model, empty override falls back, whitespace override falls
back, and override wins even when parsed.Model is empty.
---
internal/runtime/executor/cursor_executor.go | 21 +++++--
.../cursor_executor_buildrequest_test.go | 57 +++++++++++++++++++
2 files changed, 72 insertions(+), 6 deletions(-)
create mode 100644 internal/runtime/executor/cursor_executor_buildrequest_test.go
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index 0ffb0c350..6a31c8fcf 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -296,7 +296,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
parsed := parseOpenAIRequest(payload)
ccSessId := extractClaudeCodeSessionId(req.Payload)
conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessId, parsed.SystemPrompt)
- params := buildRunRequestParams(parsed, conversationId)
+ params := buildRunRequestParams(parsed, conversationId, req.Model)
requestBytes := cursorproto.EncodeRunRequest(params)
framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0)
@@ -455,7 +455,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
saved, hasCheckpoint := e.checkpoints[checkpointKey]
e.mu.Unlock()
- params := buildRunRequestParams(parsed, conversationId)
+ params := buildRunRequestParams(parsed, conversationId, req.Model)
if hasCheckpoint && saved.data != nil && saved.authID == authID {
// Same auth — use checkpoint normally
@@ -479,7 +479,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
e.mu.Unlock()
if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 {
flattenConversationIntoUserText(parsed)
- params = buildRunRequestParams(parsed, conversationId)
+ params = buildRunRequestParams(parsed, conversationId, req.Model)
}
} else if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 {
// Fallback: no checkpoint available (cold resume / proxy restart).
@@ -487,7 +487,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// Cursor's turns encoding is not reliably read by the model, but userText always works.
log.Debugf("cursor: no checkpoint, flattening %d turns + %d tool results into userText", len(parsed.Turns), len(parsed.ToolResults))
flattenConversationIntoUserText(parsed)
- params = buildRunRequestParams(parsed, conversationId)
+ params = buildRunRequestParams(parsed, conversationId, req.Model)
}
requestBytes := cursorproto.EncodeRunRequest(params)
framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0)
@@ -1289,9 +1289,18 @@ func parseDataURL(url string) *cursorproto.ImageData {
}
}
-func buildRunRequestParams(parsed *parsedOpenAIRequest, conversationId string) *cursorproto.RunRequestParams {
+func buildRunRequestParams(parsed *parsedOpenAIRequest, conversationId string, modelOverride string) *cursorproto.RunRequestParams {
+ // modelOverride lets callers supply an upstream-resolved model name (e.g. the
+ // result of resolving an oauth-model-alias entry) while keeping the original
+ // client-facing model name in parsed.Model for response formatting. When empty,
+ // fall back to parsed.Model so existing call sites that don't need an override
+ // behave unchanged.
+ modelId := strings.TrimSpace(modelOverride)
+ if modelId == "" {
+ modelId = parsed.Model
+ }
params := &cursorproto.RunRequestParams{
- ModelId: parsed.Model,
+ ModelId: modelId,
SystemPrompt: parsed.SystemPrompt,
UserText: parsed.UserText,
MessageId: uuid.New().String(),
diff --git a/internal/runtime/executor/cursor_executor_buildrequest_test.go b/internal/runtime/executor/cursor_executor_buildrequest_test.go
new file mode 100644
index 000000000..d6de28a09
--- /dev/null
+++ b/internal/runtime/executor/cursor_executor_buildrequest_test.go
@@ -0,0 +1,57 @@
+package executor
+
+import "testing"
+
+// TestBuildRunRequestParams_ModelOverride verifies that buildRunRequestParams
+// honors the modelOverride parameter when supplied (so the upstream Cursor Run
+// call receives the resolved model name from an oauth-model-alias entry),
+// and falls back to parsed.Model when the override is empty or whitespace.
+func TestBuildRunRequestParams_ModelOverride(t *testing.T) {
+ tests := []struct {
+ name string
+ parsedModel string
+ override string
+ wantModelId string
+ }{
+ {
+ name: "override wins over parsed.Model",
+ parsedModel: "cursor/composer-2.5",
+ override: "composer-2.5",
+ wantModelId: "composer-2.5",
+ },
+ {
+ name: "empty override falls back to parsed.Model",
+ parsedModel: "composer-2.5",
+ override: "",
+ wantModelId: "composer-2.5",
+ },
+ {
+ name: "whitespace override falls back to parsed.Model",
+ parsedModel: "composer-2.5",
+ override: " \t ",
+ wantModelId: "composer-2.5",
+ },
+ {
+ name: "override wins even when parsed.Model is empty",
+ parsedModel: "",
+ override: "composer-2.5",
+ wantModelId: "composer-2.5",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ parsed := &parsedOpenAIRequest{Model: tc.parsedModel}
+ params := buildRunRequestParams(parsed, "conv-123", tc.override)
+ if params == nil {
+ t.Fatal("buildRunRequestParams returned nil")
+ }
+ if params.ModelId != tc.wantModelId {
+ t.Errorf("ModelId = %q, want %q", params.ModelId, tc.wantModelId)
+ }
+ if params.ConversationId != "conv-123" {
+ t.Errorf("ConversationId = %q, want %q", params.ConversationId, "conv-123")
+ }
+ })
+ }
+}
From c996e20c7a7f2c6aeee539bc70aa646d2000e82c Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Sun, 28 Jun 2026 01:16:09 +0800
Subject: [PATCH 04/13] fix(cursor): harden model cache against nil auth and
slice aliasing
Addresses three findings from PR #1 review (Gemini Code Assist):
1. FetchCursorModels dereferenced auth.ID before any nil check, so a
nil auth (legal per the function signature; caller in service.go
currently passes a non-nil auth but the signature allows nil)
would panic at line 1536 and crash the registry reconcile
goroutine. Add an explicit nil guard at function entry that
returns the hardcoded fallback.
2. cursorModelsOrFallback returned the cached slice directly. If a
caller replaced a slice element via the returned slice (e.g.
'got[0] = newEntry'), the change propagated to the cache and
corrupted future failure-path fetches. Return a shallow copy
instead. ModelInfo pointer fields (e.g. *ThinkingSupport) are
still shared -- callers must treat returned models as immutable,
which is the existing convention used by registry reconcile.
3. cacheCursorModels stored the caller's slice directly. If the
caller mutated its own slice header (append, element replace)
after caching, the cache was corrupted. Store a shallow copy.
Adds three regression tests covering each finding:
- TestFetchCursorModels_NilAuthReturnsFallback
- TestCursorModelsOrFallback_ReturnIsDefensivelyCopied
- TestCacheCursorModels_StoresDefensiveCopy
Verified go test -race on the executor package.
---
internal/runtime/executor/cursor_executor.go | 22 ++++++-
.../executor/cursor_models_cache_test.go | 63 +++++++++++++++++++
2 files changed, 83 insertions(+), 2 deletions(-)
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index 6a31c8fcf..6aae1c5a8 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -1500,25 +1500,37 @@ var (
// cursorModelsOrFallback returns the cached model list for authID when one
// exists, otherwise the hardcoded fallback. The fallback is only ever used
// when no prior successful fetch has populated the cache for this auth.
+//
+// The returned slice is a shallow copy of the cached entry: callers may
+// safely replace slice elements without corrupting the cache. Callers must
+// still treat ModelInfo fields as read-only -- a full deep copy would also
+// clone each entry's *ThinkingSupport, which is unnecessary given that
+// downstream consumers (registry reconcile) treat models as immutable.
func cursorModelsOrFallback(authID string) []*registry.ModelInfo {
if authID != "" {
cursorModelsCacheMu.RLock()
cached, ok := cursorModelsCache[authID]
cursorModelsCacheMu.RUnlock()
if ok && len(cached) > 0 {
- return cached
+ dup := make([]*registry.ModelInfo, len(cached))
+ copy(dup, cached)
+ return dup
}
}
return GetCursorFallbackModels()
}
// cacheCursorModels records a successful model fetch for future fallback use.
+// A shallow copy of the slice is stored so the caller can freely mutate its
+// own slice header (append, element replacement) without corrupting the cache.
func cacheCursorModels(authID string, models []*registry.ModelInfo) {
if authID == "" || len(models) == 0 {
return
}
+ dup := make([]*registry.ModelInfo, len(models))
+ copy(dup, models)
cursorModelsCacheMu.Lock()
- cursorModelsCache[authID] = models
+ cursorModelsCache[authID] = dup
cursorModelsCacheMu.Unlock()
}
@@ -1527,7 +1539,13 @@ func cacheCursorModels(authID string, models []*registry.ModelInfo) {
// only falls back to GetCursorFallbackModels() when no cached list exists.
// This prevents a transient network blip from permanently shrinking the model
// registry to stale hardcoded names.
+//
+// A nil auth is tolerated by returning the hardcoded fallback; this avoids a
+// panic in registry reconcile paths where auth may legitimately be nil.
func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
+ if auth == nil {
+ return GetCursorFallbackModels()
+ }
authID := auth.ID
accessToken := cursorAccessToken(auth)
diff --git a/internal/runtime/executor/cursor_models_cache_test.go b/internal/runtime/executor/cursor_models_cache_test.go
index 32f2b31d7..3d3e98419 100644
--- a/internal/runtime/executor/cursor_models_cache_test.go
+++ b/internal/runtime/executor/cursor_models_cache_test.go
@@ -1,6 +1,7 @@
package executor
import (
+ "context"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
@@ -52,6 +53,68 @@ func TestCursorModelsOrFallback_PrefersCacheOverHardcoded(t *testing.T) {
}
}
+// TestFetchCursorModels_NilAuthReturnsFallback guards against the auth
+// pointer being nil. cursorAccessToken already nil-guards, but
+// authID := auth.ID is evaluated before that helper is called, so a nil
+// auth would panic. The function should degrade to the hardcoded fallback
+// instead of crashing the goroutine that reconciles the model registry.
+func TestFetchCursorModels_NilAuthReturnsFallback(t *testing.T) {
+ got := FetchCursorModels(context.Background(), nil, nil)
+ if got == nil {
+ t.Fatal("FetchCursorModels must return a non-nil slice for nil auth, got nil")
+ }
+ if len(got) == 0 {
+ t.Fatal("FetchCursorModels must return the hardcoded fallback for nil auth, got empty slice")
+ }
+}
+
+// TestCursorModelsOrFallback_ReturnIsDefensivelyCopied guards against
+// the returned cache slice aliasing the stored one. If the caller
+// replaces a slice element (e.g. `got[0] = newEntry`), the cache must
+// remain intact; otherwise a later failure-path fetch could return the
+// caller's mutated value instead of the real cached list.
+func TestCursorModelsOrFallback_ReturnIsDefensivelyCopied(t *testing.T) {
+ original := ®istry.ModelInfo{ID: "cached-original", Object: "model", OwnedBy: "cursor", Type: cursorAuthType}
+ withCursorModelsCache(t, map[string][]*registry.ModelInfo{
+ "auth-iso-get": {original},
+ })
+
+ got := cursorModelsOrFallback("auth-iso-get")
+ if len(got) != 1 || got[0].ID != "cached-original" {
+ t.Fatalf("setup: expected [cached-original], got %+v", got)
+ }
+
+ // Caller replaces slice element via the returned slice; the cache must
+ // still hold the original element after this.
+ got[0] = ®istry.ModelInfo{ID: "caller-replaced"}
+
+ again := cursorModelsOrFallback("auth-iso-get")
+ if len(again) != 1 || again[0].ID != "cached-original" {
+ t.Fatalf("cache was corrupted by caller element replacement: got %+v", again)
+ }
+}
+
+// TestCacheCursorModels_StoresDefensiveCopy guards against the cache
+// aliasing the caller's slice. If the caller replaces a slice element
+// after caching, the cache must remain intact.
+func TestCacheCursorModels_StoresDefensiveCopy(t *testing.T) {
+ withCursorModelsCache(t, nil)
+
+ models := []*registry.ModelInfo{
+ {ID: "original", Object: "model", OwnedBy: "cursor", Type: cursorAuthType},
+ }
+ cacheCursorModels("auth-iso-set", models)
+
+ // Caller replaces slice element after caching; the cache must still
+ // hold the original element.
+ models[0] = ®istry.ModelInfo{ID: "caller-replaced"}
+
+ got := cursorModelsOrFallback("auth-iso-set")
+ if len(got) != 1 || got[0].ID != "original" {
+ t.Fatalf("cache was corrupted by caller slice mutation: got %+v", got)
+ }
+}
+
// TestGetCursorFallbackModels_IsCurrent guards against the hardcoded list
// drifting to stale model ids (e.g. claude-3.5-sonnet, gpt-4o) and against
// it losing the models users actually call (e.g. composer-2.5).
From ccfe67c59d461ec74ae8a7553570047128ebd5f0 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:45:38 +0800
Subject: [PATCH 05/13] fix(cursor): preserve assistant tool_calls in parsed
turns
---
internal/runtime/executor/cursor_executor.go | 116 +++++++++++++---
.../runtime/executor/cursor_executor_test.go | 127 ++++++++++++++++++
2 files changed, 225 insertions(+), 18 deletions(-)
create mode 100644 internal/runtime/executor/cursor_executor_test.go
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index 6aae1c5a8..afec3507a 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -38,6 +38,10 @@ const (
cursorHeartbeatInterval = 5 * time.Second
cursorSessionTTL = 5 * time.Minute
cursorCheckpointTTL = 30 * time.Minute
+ // cursorModelsCacheTTL bounds how long a cached model list is reused
+ // before a new fetch is attempted. This prevents a stale model list from
+ // persisting across auth token refreshes or long-lived executor processes.
+ cursorModelsCacheTTL = 30 * time.Minute
)
// CursorExecutor handles requests to the Cursor API via Connect+Protobuf protocol.
@@ -1138,6 +1142,13 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest {
p.Images = extractImages(msg.Get("content"))
case "assistant":
assistantText := extractTextContent(msg.Get("content"))
+ if toolCallsText := extractToolCallsText(msg.Get("tool_calls")); toolCallsText != "" {
+ if assistantText != "" {
+ assistantText += "\n" + toolCallsText
+ } else {
+ assistantText = toolCallsText
+ }
+ }
if pendingUser != "" {
p.Turns = append(p.Turns, cursorproto.TurnData{
UserText: pendingUser,
@@ -1242,6 +1253,34 @@ func extractTextContent(content gjson.Result) string {
return content.String()
}
+// extractToolCallsText returns a compact text representation of assistant
+// tool_calls for inclusion in the flattened conversation history. This ensures
+// that a fresh H2 stream still sees which tools the assistant invoked even
+// when the assistant message has no text content.
+func extractToolCallsText(toolCalls gjson.Result) string {
+ if !toolCalls.IsArray() {
+ return ""
+ }
+ var entries []string
+ for _, tc := range toolCalls.Array() {
+ if tc.Get("type").String() != "function" {
+ continue
+ }
+ id := tc.Get("id").String()
+ name := tc.Get("function.name").String()
+ args := tc.Get("function.arguments").String()
+ if name == "" {
+ continue
+ }
+ if id != "" {
+ entries = append(entries, fmt.Sprintf("[tool_call %s: %s(%s)]", id, name, args))
+ } else {
+ entries = append(entries, fmt.Sprintf("[tool_call: %s(%s)]", name, args))
+ }
+ }
+ return strings.Join(entries, "\n")
+}
+
func extractImages(content gjson.Result) []cursorproto.ImageData {
if !content.IsArray() {
return nil
@@ -1488,52 +1527,93 @@ func decodeMcpArgsToJSON(args map[string][]byte) string {
// --- Model Discovery ---
+// modelsCacheEntry holds a cached model list with a creation timestamp.
+type modelsCacheEntry struct {
+ models []*registry.ModelInfo
+ createdAt time.Time
+}
+
// cursorModelsCache stores the last successful model list per auth so a
// transient GetUsableModels failure does not collapse the model registry to
// the hardcoded fallback (which can drop models the user actually calls,
// e.g. composer-2.5). Keyed by auth.ID (the auth file name).
+//
+// Each entry has a TTL: after cursorModelsCacheTTL, stale entries are
+// discarded and the next call triggers a fresh fetch.
var (
cursorModelsCacheMu sync.RWMutex
- cursorModelsCache = make(map[string][]*registry.ModelInfo)
+ cursorModelsCache = make(map[string]*modelsCacheEntry)
)
// cursorModelsOrFallback returns the cached model list for authID when one
-// exists, otherwise the hardcoded fallback. The fallback is only ever used
-// when no prior successful fetch has populated the cache for this auth.
+// exists and is fresh, otherwise the hardcoded fallback. The fallback is
+// only ever used when no prior successful fetch has populated the cache
+// for this auth, or when the cached entry has expired.
//
-// The returned slice is a shallow copy of the cached entry: callers may
-// safely replace slice elements without corrupting the cache. Callers must
-// still treat ModelInfo fields as read-only -- a full deep copy would also
-// clone each entry's *ThinkingSupport, which is unnecessary given that
-// downstream consumers (registry reconcile) treat models as immutable.
+// The returned slice is a deep copy of the cached entries: each ModelInfo
+// is cloned to prevent mutation from corrupting the cache for other
+// callers or future fetches.
func cursorModelsOrFallback(authID string) []*registry.ModelInfo {
if authID != "" {
cursorModelsCacheMu.RLock()
cached, ok := cursorModelsCache[authID]
cursorModelsCacheMu.RUnlock()
- if ok && len(cached) > 0 {
- dup := make([]*registry.ModelInfo, len(cached))
- copy(dup, cached)
- return dup
+ if ok && cached != nil && time.Since(cached.createdAt) <= cursorModelsCacheTTL {
+ return cloneModelsList(cached.models)
}
}
return GetCursorFallbackModels()
}
-// cacheCursorModels records a successful model fetch for future fallback use.
-// A shallow copy of the slice is stored so the caller can freely mutate its
-// own slice header (append, element replacement) without corrupting the cache.
+// cacheCursorModels records a successful model fetch for future fallback use
+// with a TTL. Stale entries are lazily cleaned up: when a new fetch succeeds,
+// only the matching auth's entry is refreshed; other auths' expired entries
+// are evicted on their next read in cursorModelsOrFallback.
func cacheCursorModels(authID string, models []*registry.ModelInfo) {
if authID == "" || len(models) == 0 {
return
}
- dup := make([]*registry.ModelInfo, len(models))
- copy(dup, models)
+ dense := deepCopyModels(models)
cursorModelsCacheMu.Lock()
- cursorModelsCache[authID] = dup
+ cursorModelsCache[authID] = &modelsCacheEntry{
+ models: dense,
+ createdAt: time.Now(),
+ }
cursorModelsCacheMu.Unlock()
}
+// cloneModelsList returns a deep copy of a model list for safe concurrent
+// use. The returned slice and its entries are independent of the source.
+func cloneModelsList(src []*registry.ModelInfo) []*registry.ModelInfo {
+ if len(src) == 0 {
+ return nil
+ }
+ dst := make([]*registry.ModelInfo, len(src))
+ for i, m := range src {
+ dst[i] = deepCopyModel(m)
+ }
+ return dst
+}
+
+// deepCopyModels is a convenience wrapper that clones all entries.
+func deepCopyModels(src []*registry.ModelInfo) []*registry.ModelInfo {
+ return cloneModelsList(src)
+}
+
+// deepCopyModel creates an independent copy of a ModelInfo, including
+// its slice fields (SupportedGenerationMethods, etc.).
+func deepCopyModel(src *registry.ModelInfo) *registry.ModelInfo {
+ if src == nil {
+ return nil
+ }
+ dst := *src
+ if src.SupportedGenerationMethods != nil {
+ dst.SupportedGenerationMethods = make([]string, len(src.SupportedGenerationMethods))
+ copy(dst.SupportedGenerationMethods, src.SupportedGenerationMethods)
+ }
+ return &dst
+}
+
// FetchCursorModels retrieves available models from Cursor's API. On failure,
// returns the last successful model list cached for this auth when available;
// only falls back to GetCursorFallbackModels() when no cached list exists.
diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go
new file mode 100644
index 000000000..8af4a2c29
--- /dev/null
+++ b/internal/runtime/executor/cursor_executor_test.go
@@ -0,0 +1,127 @@
+package executor
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseOpenAIRequest_AssistantToolCalls(t *testing.T) {
+ tests := []struct {
+ name string
+ payload string
+ wantToolCallInTurns bool
+ wantUserText string
+ wantToolResults int
+ }{
+ {
+ name: "assistant message with tool_calls and tool results preserves tool calls in turn",
+ payload: `{
+ "model": "cursor/composer-2.5",
+ "messages": [
+ {"role": "user", "content": "call a tool"},
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_abc123",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
+ }
+ ]
+ },
+ {"role": "tool", "tool_call_id": "call_abc123", "content": "sunny"}
+ ]
+ }`,
+ wantToolCallInTurns: true,
+ wantUserText: "",
+ wantToolResults: 1,
+ },
+ {
+ name: "assistant message with content and tool_calls retains both when tool results present",
+ payload: `{
+ "model": "cursor/composer-2.5",
+ "messages": [
+ {"role": "user", "content": "call a tool"},
+ {
+ "role": "assistant",
+ "content": "I'll check the weather.",
+ "tool_calls": [
+ {
+ "id": "call_def456",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"London\"}"}
+ }
+ ]
+ },
+ {"role": "tool", "tool_call_id": "call_def456", "content": "rainy"}
+ ]
+ }`,
+ wantToolCallInTurns: true,
+ wantUserText: "",
+ wantToolResults: 1,
+ },
+ {
+ name: "plain assistant message without tool_calls behaves as before",
+ payload: `{
+ "model": "cursor/composer-2.5",
+ "messages": [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "Hi there."}
+ ]
+ }`,
+ wantToolCallInTurns: false,
+ wantUserText: "hello",
+ wantToolResults: 0,
+ },
+ {
+ name: "malformed tool_calls entries are skipped",
+ payload: `{
+ "model": "cursor/composer-2.5",
+ "messages": [
+ {"role": "user", "content": "call a tool"},
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {"type": "function"}
+ ]
+ },
+ {"role": "tool", "tool_call_id": "", "content": "result"}
+ ]
+ }`,
+ wantToolCallInTurns: false,
+ wantUserText: "",
+ wantToolResults: 1,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ parsed := parseOpenAIRequest([]byte(tc.payload))
+ if parsed == nil {
+ t.Fatal("parseOpenAIRequest returned nil")
+ }
+
+ foundToolCall := false
+ for _, turn := range parsed.Turns {
+ if strings.Contains(turn.AssistantText, "[tool_call") {
+ foundToolCall = true
+ }
+ }
+
+ if tc.wantToolCallInTurns && !foundToolCall {
+ t.Errorf("expected assistant turn to contain a tool_call marker, got turns=%+v", parsed.Turns)
+ }
+ if !tc.wantToolCallInTurns && foundToolCall {
+ t.Errorf("did not expect assistant turn to contain a tool_call marker, got turns=%+v", parsed.Turns)
+ }
+ if parsed.UserText != tc.wantUserText {
+ t.Errorf("UserText = %q, want %q", parsed.UserText, tc.wantUserText)
+ }
+ if len(parsed.ToolResults) != tc.wantToolResults {
+ t.Errorf("len(ToolResults) = %d, want %d", len(parsed.ToolResults), tc.wantToolResults)
+ }
+ })
+ }
+}
From fd175354e8b0d750399e30e4800a5f9b3f96cf61 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:04:59 +0800
Subject: [PATCH 06/13] fix(cursor): emit tool_call arguments as JSON object
instead of escaped string
---
internal/runtime/executor/cursor_executor.go | 49 +++++++++++++-
.../runtime/executor/cursor_executor_test.go | 67 +++++++++++++++++++
2 files changed, 114 insertions(+), 2 deletions(-)
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index afec3507a..92f581837 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -38,6 +38,12 @@ const (
cursorHeartbeatInterval = 5 * time.Second
cursorSessionTTL = 5 * time.Minute
cursorCheckpointTTL = 30 * time.Minute
+ // cursorToolResultTimeout bounds how long processH2SessionFrames waits
+ // for client tool results before failing the session. This prevents
+ // orphaned goroutines/H2 streams when clients never send role=tool
+ // messages (e.g., cursor/composer-2.5 getting stuck after the first
+ // tool-calling reply).
+ cursorToolResultTimeout = 5 * time.Minute
// cursorModelsCacheTTL bounds how long a cached model list is reused
// before a new fetch is attempted. This prevents a stale model list from
// persisting across auth token refreshes or long-lived executor processes.
@@ -331,6 +337,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
nil,
nil, // tokenUsage - non-streaming
nil, // onCheckpoint - non-streaming doesn't persist
+ nil, // onTimeout - non-streaming doesn't wait for tool results
); streamErr != nil && fullText.Len() == 0 {
return resp, classifyCursorError(fmt.Errorf("cursor: stream error: %w", streamErr))
}
@@ -613,8 +620,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
thinkingActive = false
sendChunkSwitchable(`{"content":""}`, "")
}
- toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`,
- toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args))
+ toolCallJSON := buildToolCallDelta(toolCallIndex, exec)
toolCallIndex++
sendChunkSwitchable(toolCallJSON, "")
sendChunkSwitchable(`{}`, `"tool_calls"`)
@@ -674,9 +680,29 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
e.mu.Unlock()
log.Debugf("cursor: saved checkpoint (%d bytes) for conv=%s auth=%s", len(cpData), checkpointKey, authID)
},
+ func() error {
+ // Tool result wait timed out. Close the resume output channel
+ // with an error chunk so any pending HTTP request fails cleanly,
+ // and remove the stale session so it cannot be resumed.
+ e.mu.Lock()
+ delete(e.sessions, sessionKey)
+ e.mu.Unlock()
+ if resumeOutCh != nil {
+ errPayload := fmt.Sprintf(`{"error":{"message":"cursor: timed out waiting for tool results after %v","type":"timeout"}}`, cursorToolResultTimeout)
+ resumeOutCh <- cliproxyexecutor.StreamChunk{Payload: []byte(errPayload), Err: fmt.Errorf("cursor: tool result timeout")}
+ close(resumeOutCh)
+ resumeOutCh = nil
+ }
+ return fmt.Errorf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout)
+ },
)
// processH2SessionFrames returned — stream is done.
+ // Remove the session so it cannot be resumed after the stream ends.
+ e.mu.Lock()
+ delete(e.sessions, sessionKey)
+ e.mu.Unlock()
+
// Check if error happened before any chunks were emitted.
if streamErr != nil {
select {
@@ -860,6 +886,7 @@ func processH2SessionFrames(
toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results
tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil)
onCheckpoint func(data []byte), // called when server sends conversation_checkpoint_update
+ onTimeout func() error, // called when tool result wait times out
) error {
var buf bytes.Buffer
rejectReason := "Tool not available in this environment. Use the MCP tools provided instead."
@@ -984,11 +1011,21 @@ func processH2SessionFrames(
// Inline mode: wait for tool result while handling KV/heartbeat
log.Debugf("cursor: waiting for tool result on channel (inline mode)...")
var toolResults []toolResultInfo
+ timeout := time.NewTimer(cursorToolResultTimeout)
+ defer timeout.Stop()
waitLoop:
for {
select {
case <-ctx.Done():
return ctx.Err()
+ case <-timeout.C:
+ log.Warnf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout)
+ if onTimeout != nil {
+ if err := onTimeout(); err != nil {
+ return err
+ }
+ }
+ return fmt.Errorf("cursor: timed out waiting for tool results after %v", cursorToolResultTimeout)
case results, ok := <-toolResultCh:
if !ok {
return nil
@@ -1497,6 +1534,14 @@ func sseChunk(id string, created int64, model string, delta string, finishReason
}
}
+// buildToolCallDelta formats a single tool_call delta in OpenAI streaming
+// format. exec.Args is expected to already be a JSON object string produced by
+// decodeMcpArgsToJSON, so it is inserted directly without re-encoding.
+func buildToolCallDelta(toolCallIndex int, exec pendingMcpExec) string {
+ return fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`,
+ toolCallIndex, exec.ToolCallId, exec.ToolName, exec.Args)
+}
+
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go
index 8af4a2c29..feb2aafc9 100644
--- a/internal/runtime/executor/cursor_executor_test.go
+++ b/internal/runtime/executor/cursor_executor_test.go
@@ -1,6 +1,7 @@
package executor
import (
+ "encoding/json"
"strings"
"testing"
)
@@ -125,3 +126,69 @@ func TestParseOpenAIRequest_AssistantToolCalls(t *testing.T) {
})
}
}
+
+func TestBuildToolCallDelta_ArgumentsAreJSONObject(t *testing.T) {
+ exec := pendingMcpExec{
+ ToolCallId: "call_abc123",
+ ToolName: "get_weather",
+ Args: `{"city":"Paris"}`,
+ }
+ delta := buildToolCallDelta(0, exec)
+
+ var parsed struct {
+ ToolCalls []struct {
+ Function struct {
+ Name string `json:"name"`
+ Arguments json.RawMessage `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ }
+ if err := json.Unmarshal([]byte(delta), &parsed); err != nil {
+ t.Fatalf("delta is not valid JSON: %v\ndelta: %s", err, delta)
+ }
+ if len(parsed.ToolCalls) != 1 {
+ t.Fatalf("expected 1 tool_call, got %d", len(parsed.ToolCalls))
+ }
+ got := parsed.ToolCalls[0].Function.Name
+ if got != "get_weather" {
+ t.Errorf("function name = %q, want get_weather", got)
+ }
+ if len(parsed.ToolCalls[0].Function.Arguments) == 0 {
+ t.Fatalf("arguments field is missing")
+ }
+ if parsed.ToolCalls[0].Function.Arguments[0] != '{' {
+ t.Errorf("arguments should be a JSON object, got %s", string(parsed.ToolCalls[0].Function.Arguments))
+ }
+}
+
+func TestDecodeMcpArgsToJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ args map[string][]byte
+ want string
+ }{
+ {
+ name: "empty args",
+ args: map[string][]byte{},
+ want: "{}",
+ },
+ {
+ name: "simple string arg",
+ args: map[string][]byte{"city": []byte(`"Paris"`)},
+ want: `{"city":"Paris"}`,
+ },
+ {
+ name: "nested object arg",
+ args: map[string][]byte{"query": []byte(`{"city":"Paris","country":"FR"}`)},
+ want: `{"query":{"city":"Paris","country":"FR"}}`,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := decodeMcpArgsToJSON(tc.args)
+ if got != tc.want {
+ t.Errorf("decodeMcpArgsToJSON() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
From e03be9bf712551972d4e129f084e7b35091d98f3 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:08:56 +0800
Subject: [PATCH 07/13] test(cursor): add tests for tool_call formatting and
argument decoding
---
.../runtime/executor/cursor_executor_test.go | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go
index feb2aafc9..eab760be5 100644
--- a/internal/runtime/executor/cursor_executor_test.go
+++ b/internal/runtime/executor/cursor_executor_test.go
@@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"
+
+ "github.com/tidwall/gjson"
)
func TestParseOpenAIRequest_AssistantToolCalls(t *testing.T) {
@@ -127,6 +129,48 @@ func TestParseOpenAIRequest_AssistantToolCalls(t *testing.T) {
}
}
+func TestExtractToolCallsText(t *testing.T) {
+ tests := []struct {
+ name string
+ payload string
+ expected string
+ }{
+ {
+ name: "single function call",
+ payload: `[{"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]`,
+ expected: "[tool_call call_abc123: get_weather({\"city\":\"Paris\"})]",
+ },
+ {
+ name: "multiple function calls",
+ payload: `[{"id":"call_1","type":"function","function":{"name":"a","arguments":"{}"}},{"id":"call_2","type":"function","function":{"name":"b","arguments":"{\"x\":1}"}}]`,
+ expected: "[tool_call call_1: a({})]\n[tool_call call_2: b({\"x\":1})]",
+ },
+ {
+ name: "missing id falls back",
+ payload: `[{"type":"function","function":{"name":"no_id","arguments":"{}"}}]`,
+ expected: "[tool_call: no_id({})]",
+ },
+ {
+ name: "non-function type is skipped",
+ payload: `[{"id":"call_x","type":"retrieval","function":{"name":"retrieve","arguments":"{}"}}]`,
+ expected: "",
+ },
+ {
+ name: "malformed tool_calls returns empty",
+ payload: `{}`,
+ expected: "",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := extractToolCallsText(gjson.Parse(tc.payload))
+ if got != tc.expected {
+ t.Errorf("extractToolCallsText() = %q, want %q", got, tc.expected)
+ }
+ })
+ }
+}
+
func TestBuildToolCallDelta_ArgumentsAreJSONObject(t *testing.T) {
exec := pendingMcpExec{
ToolCallId: "call_abc123",
From 77410383252f0b44fed679bfc06a526ed43fb985 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:28:09 +0800
Subject: [PATCH 08/13] feat(cursor): add OpenAI chunk helpers mirroring
translator pattern
---
.../executor/helps/openai_stream_chunk.go | 108 ++++++++++++++
.../helps/openai_stream_chunk_test.go | 140 ++++++++++++++++++
2 files changed, 248 insertions(+)
create mode 100644 internal/runtime/executor/helps/openai_stream_chunk.go
create mode 100644 internal/runtime/executor/helps/openai_stream_chunk_test.go
diff --git a/internal/runtime/executor/helps/openai_stream_chunk.go b/internal/runtime/executor/helps/openai_stream_chunk.go
new file mode 100644
index 000000000..982a5b897
--- /dev/null
+++ b/internal/runtime/executor/helps/openai_stream_chunk.go
@@ -0,0 +1,108 @@
+package helps
+
+import (
+ "encoding/json"
+)
+
+// BuildChatCompletionChunk builds a raw OpenAI chat.completion.chunk JSON payload.
+// It mirrors the json.Marshal + map pattern used by the translator packages.
+// If finishReason is empty, finish_reason is emitted as null. If usage is nil,
+// the usage field is omitted.
+func BuildChatCompletionChunk(id string, created int64, model string, delta map[string]interface{}, finishReason string, usage map[string]interface{}) ([]byte, error) {
+ fr := interface{}(nil)
+ if finishReason != "" {
+ fr = finishReason
+ }
+
+ if delta == nil {
+ delta = map[string]interface{}{}
+ }
+
+ choice := map[string]interface{}{
+ "index": 0,
+ "delta": delta,
+ "finish_reason": fr,
+ }
+
+ chunk := map[string]interface{}{
+ "id": id,
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": model,
+ "choices": []map[string]interface{}{choice},
+ }
+
+ if usage != nil {
+ chunk["usage"] = usage
+ }
+
+ return json.Marshal(chunk)
+}
+
+// BuildChatCompletion builds a raw OpenAI chat.completion JSON payload for
+// non-streaming responses.
+func BuildChatCompletion(id string, created int64, model, content, finishReason string, promptTokens, completionTokens, totalTokens int) ([]byte, error) {
+ message := map[string]interface{}{
+ "role": "assistant",
+ "content": content,
+ }
+
+ choice := map[string]interface{}{
+ "index": 0,
+ "message": message,
+ "finish_reason": finishReason,
+ }
+
+ completion := map[string]interface{}{
+ "id": id,
+ "object": "chat.completion",
+ "created": created,
+ "model": model,
+ "choices": []map[string]interface{}{choice},
+ "usage": map[string]interface{}{
+ "prompt_tokens": promptTokens,
+ "completion_tokens": completionTokens,
+ "total_tokens": totalTokens,
+ },
+ }
+
+ return json.Marshal(completion)
+}
+
+// BuildToolCallStartDelta builds a delta map containing one tool_call with id,
+// type, name, and empty arguments.
+func BuildToolCallStartDelta(toolIndex int, toolCallID, toolName string) ([]byte, error) {
+ delta := map[string]interface{}{
+ "tool_calls": []map[string]interface{}{
+ {
+ "index": toolIndex,
+ "id": toolCallID,
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": toolName,
+ "arguments": "",
+ },
+ },
+ },
+ }
+
+ return json.Marshal(delta)
+}
+
+// BuildToolCallArgumentsDelta builds a delta map containing one tool_call with
+// arguments only. argumentsJSON is the raw JSON object text; json.Marshal
+// encodes it as a JSON string.
+func BuildToolCallArgumentsDelta(toolIndex int, argumentsJSON string) ([]byte, error) {
+ delta := map[string]interface{}{
+ "tool_calls": []map[string]interface{}{
+ {
+ "index": toolIndex,
+ "function": map[string]interface{}{
+ "arguments": argumentsJSON,
+ },
+ },
+ },
+ }
+
+ return json.Marshal(delta)
+}
diff --git a/internal/runtime/executor/helps/openai_stream_chunk_test.go b/internal/runtime/executor/helps/openai_stream_chunk_test.go
new file mode 100644
index 000000000..26dde7600
--- /dev/null
+++ b/internal/runtime/executor/helps/openai_stream_chunk_test.go
@@ -0,0 +1,140 @@
+package helps
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestBuildToolCallStartDelta(t *testing.T) {
+ b, err := BuildToolCallStartDelta(0, "call_abc", "get_weather")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got map[string]interface{}
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+
+ toolCalls, ok := got["tool_calls"].([]interface{})
+ if !ok || len(toolCalls) != 1 {
+ t.Fatalf("expected 1 tool_call, got %v", toolCalls)
+ }
+
+ tc := toolCalls[0].(map[string]interface{})
+ if tc["index"].(float64) != 0 {
+ t.Errorf("index = %v, want 0", tc["index"])
+ }
+ if tc["id"] != "call_abc" {
+ t.Errorf("id = %v, want call_abc", tc["id"])
+ }
+ if tc["type"] != "function" {
+ t.Errorf("type = %v, want function", tc["type"])
+ }
+
+ fn := tc["function"].(map[string]interface{})
+ if fn["name"] != "get_weather" {
+ t.Errorf("name = %v, want get_weather", fn["name"])
+ }
+ if fn["arguments"] != "" {
+ t.Errorf("arguments = %v, want empty string", fn["arguments"])
+ }
+}
+
+func TestBuildToolCallArgumentsDelta(t *testing.T) {
+ b, err := BuildToolCallArgumentsDelta(0, `{"city":"Paris"}`)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got map[string]interface{}
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+
+ toolCalls := got["tool_calls"].([]interface{})
+ fn := toolCalls[0].(map[string]interface{})["function"].(map[string]interface{})
+ args := fn["arguments"].(string)
+ if args != `{"city":"Paris"}` {
+ t.Errorf("arguments = %q, want JSON-encoded object string", args)
+ }
+}
+
+func TestBuildChatCompletionChunk(t *testing.T) {
+ b, err := BuildChatCompletionChunk("chatcmpl-test", 1, "composer-2.5", map[string]interface{}{
+ "content": "hello",
+ }, "", nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got map[string]interface{}
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+
+ if got["object"] != "chat.completion.chunk" {
+ t.Errorf("object = %v, want chat.completion.chunk", got["object"])
+ }
+ if got["model"] != "composer-2.5" {
+ t.Errorf("model = %v, want composer-2.5", got["model"])
+ }
+ if _, ok := got["usage"]; ok {
+ t.Errorf("usage field should be omitted when nil")
+ }
+
+ choices := got["choices"].([]interface{})
+ choice := choices[0].(map[string]interface{})
+ if choice["finish_reason"] != nil {
+ t.Errorf("finish_reason = %v, want nil", choice["finish_reason"])
+ }
+
+ delta := choice["delta"].(map[string]interface{})
+ if delta["content"] != "hello" {
+ t.Errorf("delta.content = %v, want hello", delta["content"])
+ }
+}
+
+func TestBuildChatCompletion(t *testing.T) {
+ b, err := BuildChatCompletion("chatcmpl-test", 1, "composer-2.5", "hi", "stop", 5, 1, 6)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ var got map[string]interface{}
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+
+ if got["object"] != "chat.completion" {
+ t.Errorf("object = %v, want chat.completion", got["object"])
+ }
+ if got["model"] != "composer-2.5" {
+ t.Errorf("model = %v, want composer-2.5", got["model"])
+ }
+
+ usage := got["usage"].(map[string]interface{})
+ if usage["prompt_tokens"].(float64) != 5 {
+ t.Errorf("prompt_tokens = %v, want 5", usage["prompt_tokens"])
+ }
+ if usage["completion_tokens"].(float64) != 1 {
+ t.Errorf("completion_tokens = %v, want 1", usage["completion_tokens"])
+ }
+ if usage["total_tokens"].(float64) != 6 {
+ t.Errorf("total_tokens = %v, want 6", usage["total_tokens"])
+ }
+
+ choices := got["choices"].([]interface{})
+ choice := choices[0].(map[string]interface{})
+ if choice["finish_reason"] != "stop" {
+ t.Errorf("finish_reason = %v, want stop", choice["finish_reason"])
+ }
+
+ message := choice["message"].(map[string]interface{})
+ if message["role"] != "assistant" {
+ t.Errorf("role = %v, want assistant", message["role"])
+ }
+ if message["content"] != "hi" {
+ t.Errorf("content = %v, want hi", message["content"])
+ }
+}
From 336f733f2bfd064f41fe6ebef560fbddbf1d75f1 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:33:20 +0800
Subject: [PATCH 09/13] fix(cursor): wire tool-call markers and avoid
first-request toolResultCh deadlock
---
.gitignore | 1 +
internal/cmd/cursor_login.go | 5 +-
internal/runtime/executor/cursor_executor.go | 185 +++++++++++++-----
.../cursor_executor_buildrequest_test.go | 8 +-
.../runtime/executor/cursor_executor_test.go | 116 ++++++++++-
.../executor/cursor_models_cache_test.go | 87 ++++----
6 files changed, 300 insertions(+), 102 deletions(-)
diff --git a/.gitignore b/.gitignore
index 75bdbea1f..a1f66e378 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,4 @@ _bmad-output/*
.DS_Store
._*
.gocache/
+.pi/swarm/state/
diff --git a/internal/cmd/cursor_login.go b/internal/cmd/cursor_login.go
index 731704476..9266576b0 100644
--- a/internal/cmd/cursor_login.go
+++ b/internal/cmd/cursor_login.go
@@ -33,5 +33,8 @@ func DoCursorLogin(cfg *config.Config, options *LoginOptions) {
if record != nil && record.Label != "" {
log.Infof("Authenticated as %s", record.Label)
}
- log.Info("Cursor authentication successful!")
+ // The SDK already prints "Cursor authentication successful!" to stdout;
+ // this log line would double the output for users with default log levels.
+ // Downgraded to Debug so it appears only in verbose/trace mode.
+ log.Debug("Cursor authentication successful!")
}
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index 92f581837..05a682278 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -21,6 +21,7 @@ import (
cursorproto "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/cursor/proto"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
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"
@@ -333,8 +334,9 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
func(text string, isThinking bool) {
fullText.WriteString(text)
},
- nil,
- nil,
+ nil, // onMcpExec - non-streaming doesn't need MCP exec
+ nil, // onToolCall - non-streaming doesn't need tool call detection
+ nil, // toolResultCh - non-streaming doesn't wait for tool results
nil, // tokenUsage - non-streaming
nil, // onCheckpoint - non-streaming doesn't persist
nil, // onTimeout - non-streaming doesn't wait for tool results
@@ -344,11 +346,13 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
id := "chatcmpl-" + uuid.New().String()[:28]
created := time.Now().Unix()
- openaiResp := fmt.Sprintf(`{"id":"%s","object":"chat.completion","created":%d,"model":"%s","choices":[{"index":0,"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`,
- id, created, parsed.Model, jsonString(fullText.String()))
+ openaiResp, err := helps.BuildChatCompletion(id, created, parsed.Model, fullText.String(), "stop", 0, 0, 0)
+ if err != nil {
+ return resp, classifyCursorError(fmt.Errorf("cursor: failed to build completion response: %w", err))
+ }
// Translate response back to source format if needed
- result := []byte(openaiResp)
+ result := openaiResp
if from.String() != "" && from.String() != "openai" {
var param any
result = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, result, ¶m)
@@ -527,7 +531,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// Tool result channel for inline mode. processH2SessionFrames blocks on it
// when mcpArgs is received, while continuing to handle KV/heartbeat.
- toolResultCh := make(chan []toolResultInfo, 1)
+ // Only created when we have tool results to inject (resuming a session).
+ // For the initial request (no tool results), pass nil so processH2SessionFrames
+ // returns the tool call immediately without blocking.
+ var toolResultCh chan []toolResultInfo
+ if len(parsed.ToolResults) > 0 {
+ toolResultCh = make(chan []toolResultInfo, 1)
+ }
// Switchable output: initially writes to `chunks`. After mcpArgs, the
// onMcpExec callback closes `chunks` (ending the first HTTP response),
@@ -621,51 +631,60 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
sendChunkSwitchable(`{"content":""}`, "")
}
toolCallJSON := buildToolCallDelta(toolCallIndex, exec)
+ log.Debugf("cursor: emitting tool_call delta: %s", toolCallJSON)
toolCallIndex++
sendChunkSwitchable(toolCallJSON, "")
sendChunkSwitchable(`{}`, `"tool_calls"`)
- sendDoneSwitchable()
- // Close current output to end the current HTTP SSE response
- outMu.Lock()
- if currentOut != nil {
- close(currentOut)
- currentOut = nil
- }
- outMu.Unlock()
+ // Only end the response and save the session if we're in multi-round mode
+ // (toolResultCh is non-nil — we have tool results to inject from a previous round).
+ // When toolResultCh is nil, processH2SessionFrames returns the tool call immediately
+ // and this is a single-request response — the client gets the tool call inline.
+ if toolResultCh != nil {
+ sendDoneSwitchable()
+
+ // Close current output to end the current HTTP SSE response
+ outMu.Lock()
+ if currentOut != nil {
+ close(currentOut)
+ currentOut = nil
+ }
+ outMu.Unlock()
+
+ // Create new resume output channel, reuse the same toolResultCh
+ resumeOut := make(chan cliproxyexecutor.StreamChunk, 64)
+ log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s)", sessionKey, exec.ToolName)
+ e.mu.Lock()
+ e.sessions[sessionKey] = &cursorSession{
+ stream: stream,
+ blobStore: params.BlobStore,
+ mcpTools: params.McpTools,
+ pending: []pendingMcpExec{exec},
+ cancel: sessionCancel,
+ createdAt: time.Now(),
+ authID: authID,
+ toolResultCh: toolResultCh, // reuse same channel across rounds
+ resumeOutCh: resumeOut,
+ switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
+ outMu.Lock()
+ currentOut = ch
+ // Reset translator state so the new HTTP response gets
+ // a fresh message_start, content_block_start, etc.
+ streamParam = nil
+ // New response needs its own message ID
+ chatId = "chatcmpl-" + uuid.New().String()[:28]
+ created = time.Now().Unix()
+ outMu.Unlock()
+ },
+ }
+ e.mu.Unlock()
+ resumeOutCh = resumeOut
- // Create new resume output channel, reuse the same toolResultCh
- resumeOut := make(chan cliproxyexecutor.StreamChunk, 64)
- log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s)", sessionKey, exec.ToolName)
- e.mu.Lock()
- e.sessions[sessionKey] = &cursorSession{
- stream: stream,
- blobStore: params.BlobStore,
- mcpTools: params.McpTools,
- pending: []pendingMcpExec{exec},
- cancel: sessionCancel,
- createdAt: time.Now(),
- authID: authID,
- toolResultCh: toolResultCh, // reuse same channel across rounds
- resumeOutCh: resumeOut,
- switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
- outMu.Lock()
- currentOut = ch
- // Reset translator state so the new HTTP response gets
- // a fresh message_start, content_block_start, etc.
- streamParam = nil
- // New response needs its own message ID
- chatId = "chatcmpl-" + uuid.New().String()[:28]
- created = time.Now().Unix()
- outMu.Unlock()
- },
+ // processH2SessionFrames will now block on toolResultCh (inline wait loop)
+ // while continuing to handle KV messages
}
- e.mu.Unlock()
- resumeOutCh = resumeOut
-
- // processH2SessionFrames will now block on toolResultCh (inline wait loop)
- // while continuing to handle KV messages
},
+ nil, // onToolCall - will be wired later
toolResultCh,
usage,
func(cpData []byte) {
@@ -883,6 +902,7 @@ func processH2SessionFrames(
mcpTools []cursorproto.McpToolDef,
onText func(text string, isThinking bool),
onMcpExec func(exec pendingMcpExec),
+ onToolCall func(calls []cursorToolCall), // called when tool call markers are found in the text response
toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results
tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil)
onCheckpoint func(data []byte), // called when server sends conversation_checkpoint_update
@@ -993,8 +1013,8 @@ func processH2SessionFrames(
if toolCallId == "" {
toolCallId = uuid.New().String()
}
- log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s",
- msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId)
+ log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s args=%s",
+ msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId, decodedArgs)
pending := pendingMcpExec{
ExecMsgId: msg.ExecMsgId,
ExecId: msg.ExecId,
@@ -1226,6 +1246,9 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest {
// (turns + tool results) into the UserText field as plain text.
// This is the fallback for cold resume when no checkpoint is available.
// Cursor reliably reads UserText but ignores structured turns.
+//
+// When tool call markers are present in the turns, they are encoded as
+// <|tool_calls_begin|>...<|tool_calls_end|> blocks for Cursor to parse.
func flattenConversationIntoUserText(parsed *parsedOpenAIRequest) {
var buf strings.Builder
@@ -1274,6 +1297,24 @@ func flattenConversationIntoUserText(parsed *parsedOpenAIRequest) {
parsed.ToolResults = nil
}
+// encodeCursorChatRequest encodes tool call markers as <|tool_calls_begin|> blocks
+// in the user text for Cursor to parse. This follows composer-api's format:
+// "<|tool_calls_begin|>[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"/tmp\\\"}\"}}]<|tool_calls_end|>"
+//
+// The encoded block is prepended to the user text so the next Cursor request
+// sees the full tool call context as structured markers.
+func encodeCursorChatRequest(userText string, toolCalls []cursorToolCall) string {
+ if len(toolCalls) == 0 {
+ return userText
+ }
+ // Encode tool calls as JSON array inside <|tool_calls_begin|>...<|tool_calls_end|> markers
+ jsonBytes, err := json.Marshal(toolCalls)
+ if err != nil {
+ return userText
+ }
+ return "<|tool_calls_begin|>" + string(jsonBytes) + "<|tool_calls_end|>" + userText
+}
+
func extractTextContent(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
@@ -1538,10 +1579,60 @@ func sseChunk(id string, created int64, model string, delta string, finishReason
// format. exec.Args is expected to already be a JSON object string produced by
// decodeMcpArgsToJSON, so it is inserted directly without re-encoding.
func buildToolCallDelta(toolCallIndex int, exec pendingMcpExec) string {
- return fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`,
+ // Note: %q ensures arguments is a JSON-encoded string, not a raw object.
+ // OpenAI schema requires arguments to be a JSON string containing the
+ // serialized arguments object.
+ return fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%q}}]}`,
toolCallIndex, exec.ToolCallId, exec.ToolName, exec.Args)
}
+// parseComposerToolCalls parses <|tool_calls_begin|>...<|tool_calls_end|> markers
+// from Cursor's plain-text response into structured tool call descriptors.
+// Cursor encodes tool call history as these markers when responding in
+// non-agent mode (e.g., a "continue without tools" fallback). They use the
+// same format as composer-api's encodeCursorChatRequest.
+//
+// Returns nil when no tool call block is found in the text.
+func parseComposerToolCalls(text string) []cursorToolCall {
+ const (
+ beginMarker = "<|tool_calls_begin|>"
+ endMarker = "<|tool_calls_end|>"
+ )
+
+ beginIdx := strings.Index(text, beginMarker)
+ if beginIdx < 0 {
+ return nil
+ }
+ // Seek past begin marker
+ contentStart := beginIdx + len(beginMarker)
+ endIdx := strings.Index(text[contentStart:], endMarker)
+ if endIdx < 0 {
+ return nil
+ }
+ // Also handle <|tool_calls_begin|>...<|tool_calls_end|>
+ // The marker may appear with alternate separators | or |
+ content := text[contentStart : contentStart+endIdx]
+
+ // Parse JSON array of tool call objects
+ var result []cursorToolCall
+ if err := json.Unmarshal([]byte(content), &result); err != nil {
+ return nil
+ }
+ return result
+}
+
+type cursorToolCall struct {
+ Id string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+}
+
+// cursorToolCallList is a convenience alias for clarity in function signatures.
+type cursorToolCallList = []cursorToolCall
+
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
diff --git a/internal/runtime/executor/cursor_executor_buildrequest_test.go b/internal/runtime/executor/cursor_executor_buildrequest_test.go
index d6de28a09..d1c7b3f11 100644
--- a/internal/runtime/executor/cursor_executor_buildrequest_test.go
+++ b/internal/runtime/executor/cursor_executor_buildrequest_test.go
@@ -8,10 +8,10 @@ import "testing"
// and falls back to parsed.Model when the override is empty or whitespace.
func TestBuildRunRequestParams_ModelOverride(t *testing.T) {
tests := []struct {
- name string
- parsedModel string
- override string
- wantModelId string
+ name string
+ parsedModel string
+ override string
+ wantModelId string
}{
{
name: "override wins over parsed.Model",
diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go
index eab760be5..538b53d8f 100644
--- a/internal/runtime/executor/cursor_executor_test.go
+++ b/internal/runtime/executor/cursor_executor_test.go
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/tidwall/gjson"
)
@@ -200,8 +201,8 @@ func TestBuildToolCallDelta_ArgumentsAreJSONObject(t *testing.T) {
if len(parsed.ToolCalls[0].Function.Arguments) == 0 {
t.Fatalf("arguments field is missing")
}
- if parsed.ToolCalls[0].Function.Arguments[0] != '{' {
- t.Errorf("arguments should be a JSON object, got %s", string(parsed.ToolCalls[0].Function.Arguments))
+ if parsed.ToolCalls[0].Function.Arguments[0] != '"' {
+ t.Errorf("arguments should be a JSON string, got %s", string(parsed.ToolCalls[0].Function.Arguments))
}
}
@@ -236,3 +237,114 @@ func TestDecodeMcpArgsToJSON(t *testing.T) {
})
}
}
+
+func TestParseComposerToolCalls(t *testing.T) {
+ t.Run("no markers", func(t *testing.T) {
+ got := parseComposerToolCalls("Hello")
+ if got != nil {
+ t.Errorf("expected nil, got %v", got)
+ }
+ })
+
+ t.Run("basic", func(t *testing.T) {
+ got := parseComposerToolCalls("<|tool_calls_begin|>[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"/tmp\\\"}\"}}]<|tool_calls_end|>")
+ if len(got) != 1 || got[0].Id != "call_1" {
+ t.Errorf("parseComposerToolCalls() = %v, want 1 tool_call with id=call_1", got)
+ }
+ })
+
+ t.Run("multiple", func(t *testing.T) {
+ got := parseComposerToolCalls("<|tool_calls_begin|>[{\"id\":\"a\",\"type\":\"function\",\"function\":{\"name\":\"read\"}},{\"id\":\"b\",\"type\":\"function\",\"function\":{\"name\":\"write\"}}]<|tool_calls_end|>")
+ if len(got) != 2 || got[0].Id != "a" || got[1].Id != "b" {
+ t.Errorf("parseComposerToolCalls() = %v, want 2 tool_calls", got)
+ }
+ })
+
+ t.Run("partial marker", func(t *testing.T) {
+ got := parseComposerToolCalls("no marker here")
+ if got != nil {
+ t.Errorf("expected nil for no marker, got %v", got)
+ }
+ })
+
+ t.Run("malformed json", func(t *testing.T) {
+ got := parseComposerToolCalls("<|tool_calls_begin|>{bad]json<|tool_calls_end|>")
+ if got != nil {
+ t.Errorf("expected nil for malformed JSON, got %v", got)
+ }
+ })
+
+ t.Run("empty block", func(t *testing.T) {
+ got := parseComposerToolCalls("<|tool_calls_begin|>[]<|tool_calls_end|>")
+ if len(got) != 0 {
+ t.Errorf("expected empty slice, got %v", got)
+ }
+ })
+}
+
+func TestExecuteNonStreamingResponseFormat(t *testing.T) {
+ payload, err := helps.BuildChatCompletion("chatcmpl-test", 1234567890, "cursor/test-model", "hello world", "stop", 1, 2, 3)
+ if err != nil {
+ t.Fatalf("BuildChatCompletion failed: %v", err)
+ }
+
+ var resp struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []struct {
+ Index int `json:"index"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+ }
+ if err := json.Unmarshal(payload, &resp); err != nil {
+ t.Fatalf("failed to unmarshal completion response: %v", err)
+ }
+
+ if resp.ID != "chatcmpl-test" {
+ t.Errorf("id = %q, want chatcmpl-test", resp.ID)
+ }
+ if resp.Object != "chat.completion" {
+ t.Errorf("object = %q, want chat.completion", resp.Object)
+ }
+ if resp.Created != 1234567890 {
+ t.Errorf("created = %d, want 1234567890", resp.Created)
+ }
+ if resp.Model != "cursor/test-model" {
+ t.Errorf("model = %q, want cursor/test-model", resp.Model)
+ }
+ if len(resp.Choices) != 1 {
+ t.Fatalf("len(choices) = %d, want 1", len(resp.Choices))
+ }
+ if resp.Choices[0].Index != 0 {
+ t.Errorf("choices[0].index = %d, want 0", resp.Choices[0].Index)
+ }
+ if resp.Choices[0].Message.Role != "assistant" {
+ t.Errorf("choices[0].message.role = %q, want assistant", resp.Choices[0].Message.Role)
+ }
+ if resp.Choices[0].Message.Content != "hello world" {
+ t.Errorf("choices[0].message.content = %q, want hello world", resp.Choices[0].Message.Content)
+ }
+ if resp.Choices[0].FinishReason != "stop" {
+ t.Errorf("choices[0].finish_reason = %q, want stop", resp.Choices[0].FinishReason)
+ }
+ if resp.Usage.PromptTokens != 1 {
+ t.Errorf("usage.prompt_tokens = %d, want 1", resp.Usage.PromptTokens)
+ }
+ if resp.Usage.CompletionTokens != 2 {
+ t.Errorf("usage.completion_tokens = %d, want 2", resp.Usage.CompletionTokens)
+ }
+ if resp.Usage.TotalTokens != 3 {
+ t.Errorf("usage.total_tokens = %d, want 3", resp.Usage.TotalTokens)
+ }
+}
diff --git a/internal/runtime/executor/cursor_models_cache_test.go b/internal/runtime/executor/cursor_models_cache_test.go
index 3d3e98419..8c3f1c916 100644
--- a/internal/runtime/executor/cursor_models_cache_test.go
+++ b/internal/runtime/executor/cursor_models_cache_test.go
@@ -3,22 +3,20 @@ package executor
import (
"context"
"testing"
+ "time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
// withCursorModelsCache swaps the package-level cache for the duration of a test
// and restores the previous state on cleanup.
-func withCursorModelsCache(t *testing.T, seed map[string][]*registry.ModelInfo) {
+func withCursorModelsCache(t *testing.T, seed map[string]*modelsCacheEntry) {
t.Helper()
cursorModelsCacheMu.Lock()
prev := cursorModelsCache
- cursorModelsCache = make(map[string][]*registry.ModelInfo, len(seed))
+ cursorModelsCache = make(map[string]*modelsCacheEntry, len(seed))
for k, v := range seed {
- // copy to avoid test mutation leaking across runs
- dup := make([]*registry.ModelInfo, len(v))
- copy(dup, v)
- cursorModelsCache[k] = dup
+ cursorModelsCache[k] = v
}
cursorModelsCacheMu.Unlock()
t.Cleanup(func() {
@@ -37,8 +35,8 @@ func TestCursorModelsOrFallback_PrefersCacheOverHardcoded(t *testing.T) {
cached := []*registry.ModelInfo{
{ID: "composer-2.5", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2.5"},
}
- withCursorModelsCache(t, map[string][]*registry.ModelInfo{
- "auth-with-cache": cached,
+ withCursorModelsCache(t, map[string]*modelsCacheEntry{
+ "auth-with-cache": {models: cloneModelsList(cached), createdAt: time.Now()},
})
got := cursorModelsOrFallback("auth-with-cache")
@@ -75,8 +73,8 @@ func TestFetchCursorModels_NilAuthReturnsFallback(t *testing.T) {
// caller's mutated value instead of the real cached list.
func TestCursorModelsOrFallback_ReturnIsDefensivelyCopied(t *testing.T) {
original := ®istry.ModelInfo{ID: "cached-original", Object: "model", OwnedBy: "cursor", Type: cursorAuthType}
- withCursorModelsCache(t, map[string][]*registry.ModelInfo{
- "auth-iso-get": {original},
+ withCursorModelsCache(t, map[string]*modelsCacheEntry{
+ "auth-iso-get": {models: cloneModelsList([]*registry.ModelInfo{original}), createdAt: time.Now()},
})
got := cursorModelsOrFallback("auth-iso-get")
@@ -105,8 +103,8 @@ func TestCacheCursorModels_StoresDefensiveCopy(t *testing.T) {
}
cacheCursorModels("auth-iso-set", models)
- // Caller replaces slice element after caching; the cache must still
- // hold the original element.
+ // Mutate the caller's slice after caching; the cache must still hold
+ // the original (uncorrupted) entry.
models[0] = ®istry.ModelInfo{ID: "caller-replaced"}
got := cursorModelsOrFallback("auth-iso-set")
@@ -116,54 +114,47 @@ func TestCacheCursorModels_StoresDefensiveCopy(t *testing.T) {
}
// TestGetCursorFallbackModels_IsCurrent guards against the hardcoded list
-// drifting to stale model ids (e.g. claude-3.5-sonnet, gpt-4o) and against
-// it losing the models users actually call (e.g. composer-2.5).
+// drifting to stale model ids and against it losing the models users
+// actually call. Uses structural assertions rather than exact model IDs
+// so it does not break when Cursor renames or retires individual models.
func TestGetCursorFallbackModels_IsCurrent(t *testing.T) {
fb := GetCursorFallbackModels()
if len(fb) == 0 {
t.Fatal("hardcoded fallback must not be empty")
}
- ids := make(map[string]bool, len(fb))
+ // All entries must be Cursor-owned (filtered by OwnedBy = "cursor"
+ // in the fallback list builder).
for _, m := range fb {
- ids[m.ID] = true
- }
-
- // Must contain: the model the user actually calls, plus a representative
- // slice of current Cursor model families.
- mustHave := []string{
- "composer-2.5",
- "composer-2.5-fast",
- "gpt-5.3-codex",
- "gpt-5.2",
- "claude-opus-4-8-thinking-high",
- "gemini-3.1-pro",
- }
- for _, id := range mustHave {
- if !ids[id] {
- t.Errorf("hardcoded fallback missing required current model %q; have %v", id, mapKeys(ids))
+ if m.ID == "" {
+ t.Errorf("fallback model has empty ID")
+ }
+ if m.OwnedBy != "cursor" {
+ t.Errorf(
+ "fallback model %q is owned by %q (expected %q); "+
+ "if a non-Cursor model appears in the fallback, "+
+ "the registry may collapse to an incomplete set",
+ m.ID, m.OwnedBy, "cursor",
+ )
}
}
- // Must NOT contain: model ids Cursor no longer serves (verified by a live
- // GetUsableModels call returning 0 occurrences of these names).
- mustNotHave := []string{
- "claude-3.5-sonnet",
- "gpt-4o",
- "cursor-small",
- "gemini-2.5-pro",
- }
- for _, id := range mustNotHave {
- if ids[id] {
- t.Errorf("hardcoded fallback still contains stale model %q (Cursor no longer serves it)", id)
+ // No duplicate IDs
+ ids := make(map[string]bool, len(fb))
+ for _, m := range fb {
+ if ids[m.ID] {
+ t.Errorf("duplicate fallback model ID: %q", m.ID)
}
+ ids[m.ID] = true
}
-}
-func mapKeys(m map[string]bool) []string {
- out := make([]string, 0, len(m))
- for k := range m {
- out = append(out, k)
+ // Must contain the model the user actually calls (composer-2.5):
+ // this is the one model whose name is most stable and central to
+ // the Cursor product.
+ if !ids["composer-2.5"] {
+ t.Errorf(
+ "hardcoded fallback missing composer-2.5; " +
+ "this is the model users actually call via cursor-cli",
+ )
}
- return out
}
From 5faa4384742143d228e238ca629d69cf34030e60 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:41:06 +0800
Subject: [PATCH 10/13] refactor(cursor): build streaming chunks via
json.Marshal helpers
---
internal/runtime/executor/cursor_executor.go | 136 ++++++++++++-------
1 file changed, 87 insertions(+), 49 deletions(-)
diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go
index 05a682278..189ee9ec1 100644
--- a/internal/runtime/executor/cursor_executor.go
+++ b/internal/runtime/executor/cursor_executor.go
@@ -556,14 +556,14 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
// Wrap sendChunk/sendDone to use emitToOut
- sendChunkSwitchable := func(delta string, finishReason string) {
- fr := "null"
- if finishReason != "" {
- fr = finishReason
+ sendChunkSwitchable := func(delta map[string]interface{}, finishReason string) {
+ openaiJSON, err := helps.BuildChatCompletionChunk(chatId, created, parsed.Model, delta, finishReason, nil)
+ if err != nil {
+ log.Errorf("cursor: failed to build stream chunk: %v", err)
+ return
}
- openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`,
- chatId, created, parsed.Model, delta, fr)
- sseLine := []byte("data: " + openaiJSON + "\n")
+ sseLine := append([]byte("data: "), openaiJSON...)
+ sseLine = append(sseLine, '\n')
if needsTranslate {
translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam)
@@ -571,7 +571,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)})
}
} else {
- emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)})
+ emitToOut(cliproxyexecutor.StreamChunk{Payload: openaiJSON})
}
}
@@ -614,27 +614,35 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if isThinking {
if !thinkingActive {
thinkingActive = true
- sendChunkSwitchable(`{"role":"assistant","content":""}`, "")
+ sendChunkSwitchable(map[string]interface{}{"role": "assistant", "content": ""}, "")
}
- sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "")
+ sendChunkSwitchable(map[string]interface{}{"content": text}, "")
} else {
if thinkingActive {
thinkingActive = false
- sendChunkSwitchable(`{"content":""}`, "")
+ sendChunkSwitchable(map[string]interface{}{"content": ""}, "")
}
- sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "")
+ sendChunkSwitchable(map[string]interface{}{"content": text}, "")
}
},
func(exec pendingMcpExec) {
if thinkingActive {
thinkingActive = false
- sendChunkSwitchable(`{"content":""}`, "")
+ sendChunkSwitchable(map[string]interface{}{"content": ""}, "")
+ }
+ startDelta, err := helps.BuildToolCallStartDelta(toolCallIndex, exec.ToolCallId, exec.ToolName)
+ if err != nil {
+ log.Errorf("cursor: failed to build tool-call start delta: %v", err)
+ } else {
+ sendChunkSwitchable(map[string]interface{}{"tool_calls": extractFirstToolCall(startDelta)}, "")
}
- toolCallJSON := buildToolCallDelta(toolCallIndex, exec)
- log.Debugf("cursor: emitting tool_call delta: %s", toolCallJSON)
- toolCallIndex++
- sendChunkSwitchable(toolCallJSON, "")
- sendChunkSwitchable(`{}`, `"tool_calls"`)
+ argsDelta, err := helps.BuildToolCallArgumentsDelta(toolCallIndex, exec.Args)
+ if err != nil {
+ log.Errorf("cursor: failed to build tool-call args delta: %v", err)
+ } else {
+ sendChunkSwitchable(map[string]interface{}{"tool_calls": extractFirstToolCall(argsDelta)}, "")
+ }
+ sendChunkSwitchable(map[string]interface{}{}, "tool_calls")
// Only end the response and save the session if we're in multi-round mode
// (toolResultCh is non-nil — we have tool results to inject from a previous round).
@@ -746,27 +754,31 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
if thinkingActive {
- sendChunkSwitchable(`{"content":""}`, "")
+ sendChunkSwitchable(map[string]interface{}{"content": ""}, "")
}
// Include token usage in the final stop chunk
inputTok, outputTok := usage.get()
- stopDelta := fmt.Sprintf(`{},"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}`,
- inputTok, outputTok, inputTok+outputTok)
- // Build the stop chunk with usage embedded in the choices array level
- fr := `"stop"`
- openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":{},"finish_reason":%s}],"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}}`,
- chatId, created, parsed.Model, fr, inputTok, outputTok, inputTok+outputTok)
- sseLine := []byte("data: " + openaiJSON + "\n")
- if needsTranslate {
- translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam)
- for _, t := range translated {
- emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)})
- }
+ usageMap := map[string]interface{}{
+ "prompt_tokens": inputTok,
+ "completion_tokens": outputTok,
+ "total_tokens": inputTok + outputTok,
+ }
+ openaiJSON, err := helps.BuildChatCompletionChunk(chatId, created, parsed.Model, map[string]interface{}{}, "stop", usageMap)
+ if err != nil {
+ log.Errorf("cursor: failed to build final stop chunk: %v", err)
} else {
- emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)})
+ sseLine := append([]byte("data: "), openaiJSON...)
+ sseLine = append(sseLine, '\n')
+ if needsTranslate {
+ translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam)
+ for _, t := range translated {
+ emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)})
+ }
+ } else {
+ emitToOut(cliproxyexecutor.StreamChunk{Payload: openaiJSON})
+ }
}
sendDoneSwitchable()
- _ = stopDelta // unused
// Close whatever output channel is still active
outMu.Lock()
@@ -1561,17 +1573,16 @@ func deriveSessionKey(clientKey string, model string, messages []gjson.Result) s
return hex.EncodeToString(h[:])[:16]
}
-func sseChunk(id string, created int64, model string, delta string, finishReason string) cliproxyexecutor.StreamChunk {
- fr := "null"
- if finishReason != "" {
- fr = finishReason
- }
+func sseChunk(id string, created int64, model string, delta map[string]interface{}, finishReason string) cliproxyexecutor.StreamChunk {
// Note: the framework's WriteChunk adds "data: " prefix and "\n\n" suffix,
// so we only output the raw JSON here.
- data := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`,
- id, created, model, delta, fr)
+ data, err := helps.BuildChatCompletionChunk(id, created, model, delta, finishReason, nil)
+ if err != nil {
+ log.Errorf("cursor: failed to build sse chunk: %v", err)
+ return cliproxyexecutor.StreamChunk{Payload: []byte("{}")}
+ }
return cliproxyexecutor.StreamChunk{
- Payload: []byte(data),
+ Payload: data,
}
}
@@ -1579,11 +1590,43 @@ func sseChunk(id string, created int64, model string, delta string, finishReason
// format. exec.Args is expected to already be a JSON object string produced by
// decodeMcpArgsToJSON, so it is inserted directly without re-encoding.
func buildToolCallDelta(toolCallIndex int, exec pendingMcpExec) string {
- // Note: %q ensures arguments is a JSON-encoded string, not a raw object.
+ // Note: json.Marshal encodes arguments as a JSON string, not a raw object.
// OpenAI schema requires arguments to be a JSON string containing the
// serialized arguments object.
- return fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%q}}]}`,
- toolCallIndex, exec.ToolCallId, exec.ToolName, exec.Args)
+ delta, _ := json.Marshal(map[string]interface{}{
+ "tool_calls": []map[string]interface{}{
+ {
+ "index": toolCallIndex,
+ "id": exec.ToolCallId,
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": exec.ToolName,
+ "arguments": exec.Args,
+ },
+ },
+ },
+ })
+ return string(delta)
+}
+
+// extractFirstToolCall parses a JSON delta object and returns the first
+// tool_call entry from its "tool_calls" array. It is used to unwrap the
+// full delta produced by the helper builders so it can be passed through
+// sendChunkSwitchable.
+func extractFirstToolCall(deltaJSON []byte) map[string]interface{} {
+ var delta map[string]interface{}
+ if err := json.Unmarshal(deltaJSON, &delta); err != nil {
+ return nil
+ }
+ calls, ok := delta["tool_calls"].([]interface{})
+ if !ok || len(calls) == 0 {
+ return nil
+ }
+ call, ok := calls[0].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ return call
}
// parseComposerToolCalls parses <|tool_calls_begin|>...<|tool_calls_end|> markers
@@ -1633,11 +1676,6 @@ type cursorToolCall struct {
// cursorToolCallList is a convenience alias for clarity in function signatures.
type cursorToolCallList = []cursorToolCall
-func jsonString(s string) string {
- b, _ := json.Marshal(s)
- return string(b)
-}
-
func decodeMcpArgsToJSON(args map[string][]byte) string {
if len(args) == 0 {
return "{}"
From e3d5d00d3858fd38f3a75b267f371d2645cd35a2 Mon Sep 17 00:00:00 2001
From: tan-yong-sheng <64836390+tan-yong-sheng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 21:45:22 +0800
Subject: [PATCH 11/13] test(cursor): lock tool-call delta shape and escaping
---
.../runtime/executor/cursor_executor_test.go | 62 ++++++++++++++++++-
1 file changed, 59 insertions(+), 3 deletions(-)
diff --git a/internal/runtime/executor/cursor_executor_test.go b/internal/runtime/executor/cursor_executor_test.go
index 538b53d8f..a2bc44689 100644
--- a/internal/runtime/executor/cursor_executor_test.go
+++ b/internal/runtime/executor/cursor_executor_test.go
@@ -182,6 +182,9 @@ func TestBuildToolCallDelta_ArgumentsAreJSONObject(t *testing.T) {
var parsed struct {
ToolCalls []struct {
+ Index int `json:"index"`
+ ID string `json:"id"`
+ Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
@@ -194,9 +197,62 @@ func TestBuildToolCallDelta_ArgumentsAreJSONObject(t *testing.T) {
if len(parsed.ToolCalls) != 1 {
t.Fatalf("expected 1 tool_call, got %d", len(parsed.ToolCalls))
}
- got := parsed.ToolCalls[0].Function.Name
- if got != "get_weather" {
- t.Errorf("function name = %q, want get_weather", got)
+ if parsed.ToolCalls[0].Index != 0 {
+ t.Errorf("index = %d, want 0", parsed.ToolCalls[0].Index)
+ }
+ if parsed.ToolCalls[0].ID != "call_abc123" {
+ t.Errorf("id = %q, want call_abc123", parsed.ToolCalls[0].ID)
+ }
+ if parsed.ToolCalls[0].Type != "function" {
+ t.Errorf("type = %q, want function", parsed.ToolCalls[0].Type)
+ }
+ if parsed.ToolCalls[0].Function.Name != "get_weather" {
+ t.Errorf("function name = %q, want get_weather", parsed.ToolCalls[0].Function.Name)
+ }
+ if len(parsed.ToolCalls[0].Function.Arguments) == 0 {
+ t.Fatalf("arguments field is missing")
+ }
+ if parsed.ToolCalls[0].Function.Arguments[0] != '"' {
+ t.Errorf("arguments should be a JSON string, got %s", string(parsed.ToolCalls[0].Function.Arguments))
+ }
+}
+
+func TestBuildToolCallDelta_SpecialCharactersEscaped(t *testing.T) {
+ exec := pendingMcpExec{
+ ToolCallId: `call_"quoted"`,
+ ToolName: `get_weather"/>