authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-25 19:27:37-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 19:27:37-05:00
logaade3a69a8536be98d2fe9b0fb9193289b70e90b
treea7cdaa644d226f18d0fae9b80fe8b3b51f2720ab
parent525af2d1ad4b6de847ea658c37a081fd7afe8ef6
signature Signed by PGP key B5690EEEBB952194

fix(usage): price Cursor cache savings by base model (#13731)


4 files changed, 46 insertions(+), 3 deletions(-)

apps/server/src/usage/cursorUsageReader.ts+13
...@@ -17,6 +17,18 @@ function tokens(value: unknown): number {...@@ -17,6 +17,18 @@ function tokens(value: unknown): number {
17 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;17 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
18}18}
1919
20/**
21 * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`,
22 * `claude-fable-5-1-thinking-high`) to the base model's rate-table key.
23 * Grok resolves through xAI's first-party entry, which has no bare alias.
24 */
25export function cursorRateModel(model: string): string {
26 const base = model
27 .replace(/^cursor-/, "")
28 .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, "");
29 return base.startsWith("grok-") ? `xai/${base}` : base;
30}
31
20export interface CursorAccountUsageReadResult {32export interface CursorAccountUsageReadResult {
21 readonly accountKey: string | null;33 readonly accountKey: string | null;
22 readonly records: readonly UsageRecord[];34 readonly records: readonly UsageRecord[];
...@@ -232,6 +244,7 @@ export async function readCursorAccountUsage(...@@ -232,6 +244,7 @@ export async function readCursorAccountUsage(
232 provider: "cursor",244 provider: "cursor",
233 timestampMs,245 timestampMs,
234 model: event.model,246 model: event.model,
247 rateModel: cursorRateModel(event.model),
235 sessionId,248 sessionId,
236 totals,249 totals,
237 reportedCostUsd,250 reportedCostUsd,
apps/server/src/usage/usagePricing.test.ts+21
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { cursorRateModel } from "./cursorUsageReader.ts";
3import {4import {
4 cacheSavingsUsd,5 cacheSavingsUsd,
5 createOverrideRateTable,6 createOverrideRateTable,
...@@ -49,6 +50,26 @@ describe("usage pricing", () => {...@@ -49,6 +50,26 @@ describe("usage pricing", () => {
49 expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5);50 expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5);
50 });51 });
5152
53 it("prices Cursor cache savings at the base model rate", () => {
54 const table = parseRateTable({
55 "claude-fable-5-1": rate(10e-6, 1e-6),
56 "xai/grok-4.7": rate(2e-6, 0.5e-6),
57 "openrouter/x-ai/grok-4.7": rate(3e-6, 0.5e-6),
58 });
59 const cursorRecord = (model: string) => ({
60 ...record(model, 0.25),
61 rateModel: cursorRateModel(model),
62 });
63
64 expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9);
65 expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5);
66 expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0);
67 expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({
68 costUsd: 0.25,
69 costSource: "providerReported",
70 });
71 });
72
52 it("prices unknown models offline and uses input prices for omitted cache rates", () => {73 it("prices unknown models offline and uses input prices for omitted cache rates", () => {
53 const table = parseRateTable({});74 const table = parseRateTable({});
54 const overrides = createOverrideRateTable({75 const overrides = createOverrideRateTable({
apps/server/src/usage/usagePricing.ts+7-3
...@@ -184,7 +184,10 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null {...@@ -184,7 +184,10 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null {
184}184}
185185
186/** The parts of a transcript record that decide its price. */186/** The parts of a transcript record that decide its price. */
187export type PricedRecord = Pick<UsageRecord, "model" | "totals" | "fast" | "reportedCostUsd">;187export type PricedRecord = Pick<
188 UsageRecord,
189 "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd"
190>;
188191
189export interface PricedUsage {192export interface PricedUsage {
190 readonly costUsd: number;193 readonly costUsd: number;
...@@ -208,7 +211,7 @@ export function priceUsage(...@@ -208,7 +211,7 @@ export function priceUsage(
208 return { costUsd: reportedCostUsd, costSource: "providerReported" };211 return { costUsd: reportedCostUsd, costSource: "providerReported" };
209 }212 }
210213
211 const rate = override ?? lookupRate(table, model);214 const rate = override ?? lookupRate(table, record.rateModel ?? model);
212 if (rate === null) return { costUsd: 0, costSource: "unpriced" };215 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
213216
214 const standardCostUsd =217 const standardCostUsd =
...@@ -232,7 +235,8 @@ export function cacheSavingsUsd(...@@ -232,7 +235,8 @@ export function cacheSavingsUsd(
232 record: PricedRecord,235 record: PricedRecord,
233 overrides?: RateTable,236 overrides?: RateTable,
234): number {237): number {
235 const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.model);238 const rate =
239 overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model);
236 if (rate === null) return 0;240 if (rate === null) return 0;
237 return (241 return (
238 record.totals.cachedInputTokens *242 record.totals.cachedInputTokens *
apps/server/src/usage/usageTranscripts.ts+5
...@@ -12,6 +12,11 @@ export interface UsageRecord {...@@ -12,6 +12,11 @@ export interface UsageRecord {
12 readonly provider: UsageProviderKind;12 readonly provider: UsageProviderKind;
13 readonly timestampMs: number;13 readonly timestampMs: number;
14 readonly model: string;14 readonly model: string;
15 /**
16 * Rate-table key when the provider's display name carries tiers the table
17 * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`.
18 */
19 readonly rateModel?: string;
15 readonly sessionId: string;20 readonly sessionId: string;
16 readonly totals: UsageTokenTotals;21 readonly totals: UsageTokenTotals;
17 readonly reportedCostUsd: number | null;22 readonly reportedCostUsd: number | null;