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 <cursoragent@cursor.com>

* fix(views): align Cursor Composer pricing

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
marovole
2026-06-15 15:52:48 +08:00
committed by GitHub
parent 71eb938a67
commit 0e31a9ca58
4 changed files with 117 additions and 18 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)