From 0e31a9ca58cdeda1b4b1559f53c4ff373cb067c2 Mon Sep 17 00:00:00 2001 From: marovole Date: Mon, 15 Jun 2026 15:52:48 +0800 Subject: [PATCH] fix(agent/runtimes): show Cursor Composer token usage and billing (#4135) * fix(agent/runtimes): show Cursor Composer token usage and billing Attribute Cursor stream-json usage to the configured runtime model when result events omit `model`, and add Composer/Auto pricing so dashboard cost estimates resolve for composer-2.5 runs. Co-authored-by: Cursor * fix(views): align Cursor Composer pricing Co-authored-by: multica-agent --------- Co-authored-by: Cursor Co-authored-by: Eve Co-authored-by: multica-agent --- packages/views/runtimes/utils.test.ts | 33 +++++++++++++++ packages/views/runtimes/utils.ts | 18 ++++++++ server/pkg/agent/cursor.go | 23 ++++++---- server/pkg/agent/cursor_test.go | 61 +++++++++++++++++++++++---- 4 files changed, 117 insertions(+), 18 deletions(-) diff --git a/packages/views/runtimes/utils.test.ts b/packages/views/runtimes/utils.test.ts index 9f1d74200..f9140fb15 100644 --- a/packages/views/runtimes/utils.test.ts +++ b/packages/views/runtimes/utils.test.ts @@ -263,6 +263,39 @@ describe("estimateCost", () => { ).toBe(0); }); + it("prices Cursor Composer rows at the published rates without cache-write spend", () => { + const costWithAllTokenTypes = (model: string) => + estimateCost({ + ...zeroUsage, + model, + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_tokens: 1_000_000, + cache_write_tokens: 1_000_000, + }); + + expect(costWithAllTokenTypes("auto")).toBeCloseTo(1.25 + 6 + 0.25, 5); + expect(costWithAllTokenTypes("composer-2.5-fast")).toBeCloseTo( + 3 + 15 + 0.5, + 5, + ); + expect(costWithAllTokenTypes("composer-2.5")).toBeCloseTo(0.5 + 2.5 + 0.2, 5); + expect(costWithAllTokenTypes("composer-2-fast")).toBeCloseTo( + 1.5 + 7.5 + 0.35, + 5, + ); + expect(costWithAllTokenTypes("composer-2")).toBeCloseTo(0.5 + 2.5 + 0.2, 5); + expect(costWithAllTokenTypes("composer-1.5")).toBeCloseTo( + 3.5 + 17.5 + 0.35, + 5, + ); + expect(costWithAllTokenTypes("composer-1")).toBeCloseTo( + 1.25 + 10 + 0.125, + 5, + ); + expect(costWithAllTokenTypes("cursor")).toBeCloseTo(3 + 15 + 0.5, 5); + }); + // The Chinese-model rates below are spot-checked against the literal // numbers on the three official price sheets cited in MODEL_PRICING's // header comment. Pinning them in tests is what catches a future edit diff --git a/packages/views/runtimes/utils.ts b/packages/views/runtimes/utils.ts index 37e8e4e67..8ba30376c 100644 --- a/packages/views/runtimes/utils.ts +++ b/packages/views/runtimes/utils.ts @@ -228,6 +228,24 @@ const MODEL_PRICING: Record< "glm-4.5-air": { input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0.2 }, "glm-4.5-airx": { input: 1.1, output: 4.5, cacheRead: 0.22, cacheWrite: 1.1 }, "glm-4.5-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + + // -- Cursor Composer / Auto (cursor.com/docs/models-and-pricing, + // cursor.com/docs/models/cursor-composer-2, + // cursor.com/docs/models/cursor-composer-2-5). + // Cursor result events often omit `model`, so the daemon falls back to + // the configured runtime model or the legacy key `cursor`. + // Cursor does not publish a cache-write rate for these rows; keep it at + // 0 so reported cache_write_tokens don't invent spend from input pricing. + "auto": { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 0 }, + "composer-2.5-fast": { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 }, + "composer-2.5": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, + "composer-2-fast": { input: 1.5, output: 7.5, cacheRead: 0.35, cacheWrite: 0 }, + "composer-2": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 }, + "composer-1.5": { input: 3.5, output: 17.5, cacheRead: 0.35, cacheWrite: 0 }, + "composer-1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + // Legacy fallback bucket when neither the result event nor the runtime + // model is known — price at the current Composer 2.5 Fast default. + "cursor": { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 }, }; // Resolve a model string to its pricing tier. Exact match, with four diff --git a/server/pkg/agent/cursor.go b/server/pkg/agent/cursor.go index e9f5cffa6..620d3b89d 100644 --- a/server/pkg/agent/cursor.go +++ b/server/pkg/agent/cursor.go @@ -74,6 +74,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt }() startTime := time.Now() + configuredModel := strings.TrimSpace(opts.Model) var output strings.Builder var sessionID string finalStatus := "completed" @@ -149,7 +150,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if evt.ResultText != "" && output.Len() == 0 { output.WriteString(evt.ResultText) } - b.accumulateResultUsage(resultUsage, &evt) + b.accumulateResultUsage(resultUsage, &evt, configuredModel) if evt.hasResultUsage() { hasResultUsage = true } @@ -179,10 +180,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if evt.Part != nil { var part cursorStepFinishPart _ = json.Unmarshal(evt.Part, &part) - model := evt.Model - if model == "" { - model = "cursor" - } + model := cursorUsageModel(evt.Model, configuredModel) u := stepUsage[model] u.InputTokens += int64(part.Tokens.Input) u.OutputTokens += int64(part.Tokens.Output) @@ -267,11 +265,18 @@ func (b *cursorBackend) handleCursorAssistant(evt *cursorStreamEvent, ch chan<- } } -func (b *cursorBackend) accumulateResultUsage(usage map[string]TokenUsage, evt *cursorStreamEvent) { - model := evt.Model - if model == "" { - model = "cursor" +func cursorUsageModel(evtModel, configuredModel string) string { + if model := strings.TrimSpace(evtModel); model != "" { + return model } + if model := strings.TrimSpace(configuredModel); model != "" { + return model + } + return "cursor" +} + +func (b *cursorBackend) accumulateResultUsage(usage map[string]TokenUsage, evt *cursorStreamEvent, configuredModel string) { + model := cursorUsageModel(evt.Model, configuredModel) u := usage[model] // Cursor agent has emitted token usage in multiple shapes: top-level diff --git a/server/pkg/agent/cursor_test.go b/server/pkg/agent/cursor_test.go index 4bca81784..464923306 100644 --- a/server/pkg/agent/cursor_test.go +++ b/server/pkg/agent/cursor_test.go @@ -251,6 +251,49 @@ func TestCursorErrorText(t *testing.T) { } } +func TestCursorUsageModelFallback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + evtModel string + configuredModel string + want string + }{ + {"event model wins", "gpt-5.3-codex", "composer-2.5", "gpt-5.3-codex"}, + {"configured model fallback", "", "composer-2.5", "composer-2.5"}, + {"default cursor", "", "", "cursor"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cursorUsageModel(tc.evtModel, tc.configuredModel) + if got != tc.want { + t.Fatalf("cursorUsageModel(%q, %q) = %q, want %q", tc.evtModel, tc.configuredModel, got, tc.want) + } + }) + } +} + +func TestCursorAccumulateResultUsageUsesConfiguredModel(t *testing.T) { + t.Parallel() + + b := &cursorBackend{cfg: Config{Logger: slog.Default()}} + usage := make(map[string]TokenUsage) + evt := &cursorStreamEvent{ + InputTokens: 400, + OutputTokens: 200, + } + b.accumulateResultUsage(usage, evt, "composer-2.5") + u := usage["composer-2.5"] + if u.InputTokens != 400 || u.OutputTokens != 200 { + t.Fatalf("unexpected usage: %+v", u) + } + if _, ok := usage["cursor"]; ok { + t.Fatalf("expected configured model key, got cursor fallback: %+v", usage) + } +} + func TestCursorAccumulateResultUsage(t *testing.T) { t.Parallel() @@ -269,7 +312,7 @@ func TestCursorAccumulateResultUsage(t *testing.T) { CacheWriteInputTokens: 25, }, } - b.accumulateResultUsage(usage, evt) + b.accumulateResultUsage(usage, evt, "") u := usage["gpt-5.3"] if u.InputTokens != 200 || u.OutputTokens != 100 || u.CacheReadTokens != 50 || u.CacheWriteTokens != 25 { t.Fatalf("unexpected usage: %+v", u) @@ -288,7 +331,7 @@ func TestCursorAccumulateResultUsage(t *testing.T) { CacheReadTokens: 75, CacheWriteTokens: 25, } - b.accumulateResultUsage(usage, evt) + b.accumulateResultUsage(usage, evt, "") u := usage["gpt-5.3"] if u.InputTokens != 300 || u.OutputTokens != 150 || u.CacheReadTokens != 75 || u.CacheWriteTokens != 25 { t.Fatalf("unexpected usage: %+v (want input=300 output=150 cache_read=75 cache_write=25)", u) @@ -309,7 +352,7 @@ func TestCursorAccumulateResultUsage(t *testing.T) { CacheReadInputTokens: 777, }, } - b.accumulateResultUsage(usage, evt) + b.accumulateResultUsage(usage, evt, "") u := usage["gpt-5.3"] if u.InputTokens != 300 || u.OutputTokens != 150 || u.CacheReadTokens != 0 { t.Fatalf("unexpected usage: %+v (want input=300 output=150 cache=0)", u) @@ -322,7 +365,7 @@ func TestCursorAccumulateResultUsage(t *testing.T) { evt := &cursorStreamEvent{ Model: "gpt-5.3", } - b.accumulateResultUsage(usage, evt) + b.accumulateResultUsage(usage, evt, "") if _, ok := usage["gpt-5.3"]; ok { t.Fatalf("expected no entry, got %+v", usage["gpt-5.3"]) } @@ -335,7 +378,7 @@ func TestCursorAccumulateResultUsage(t *testing.T) { InputTokens: 50, OutputTokens: 25, } - b.accumulateResultUsage(usage, evt) + b.accumulateResultUsage(usage, evt, "") u := usage["cursor"] if u.InputTokens != 50 || u.OutputTokens != 25 { t.Fatalf("unexpected usage: %+v (want input=50 output=25)", u) @@ -502,7 +545,7 @@ func TestCursorUsageNoDoubleCount(t *testing.T) { switch evt.Type { case "result": - b.accumulateResultUsage(resultUsage, &evt) + b.accumulateResultUsage(resultUsage, &evt, "") if evt.hasResultUsage() { hasResultUsage = true } @@ -580,7 +623,7 @@ func TestCursorStreamEventUnmarshalTopLevelCamelCase(t *testing.T) { // Verify accumulateResultUsage processes the new shape. b := &cursorBackend{cfg: Config{Logger: slog.Default()}} usage := make(map[string]TokenUsage) - b.accumulateResultUsage(usage, &evt) + b.accumulateResultUsage(usage, &evt, "") u := usage["gpt-5.3"] if u.InputTokens != 1500 || u.OutputTokens != 300 || u.CacheReadTokens != 75 || u.CacheWriteTokens != 25 { t.Fatalf("accumulated usage = %+v, want input=1500 output=300 cache_read=75 cache_write=25", u) @@ -607,7 +650,7 @@ func TestCursorStreamEventUnmarshalNestedCamelCase(t *testing.T) { b := &cursorBackend{cfg: Config{Logger: slog.Default()}} usage := make(map[string]TokenUsage) - b.accumulateResultUsage(usage, &evt) + b.accumulateResultUsage(usage, &evt, "") u := usage["cursor"] if u.InputTokens != 26640 || u.OutputTokens != 40 || u.CacheReadTokens != 467 || u.CacheWriteTokens != 12 { t.Fatalf("accumulated usage = %+v, want input=26640 output=40 cache_read=467 cache_write=12", u) @@ -637,7 +680,7 @@ func TestCursorStreamEventUnmarshalLegacyUsage(t *testing.T) { b := &cursorBackend{cfg: Config{Logger: slog.Default()}} usage := make(map[string]TokenUsage) - b.accumulateResultUsage(usage, &evt) + b.accumulateResultUsage(usage, &evt, "") u := usage["gpt-5"] if u.InputTokens != 800 || u.OutputTokens != 400 || u.CacheReadTokens != 200 || u.CacheWriteTokens != 100 { t.Fatalf("accumulated usage = %+v, want input=800 output=400 cache_read=200 cache_write=100", u)