mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user