| author | |
| committer | |
| log | aade3a69a8536be98d2fe9b0fb9193289b70e90b |
| tree | a7cdaa644d226f18d0fae9b80fe8b3b51f2720ab |
| parent | 525af2d1ad4b6de847ea658c37a081fd7afe8ef6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 46 insertions(+), 3 deletions(-)
apps/server/src/usage/cursorUsageReader.ts+13| ... | ... | @@ -17,6 +17,18 @@ function tokens(value: unknown): number { |
| 17 | 17 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; |
| 18 | 18 | } |
| 19 | 19 | |
| 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 | */ | |
| 25 | export 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 | ||
| 20 | 32 | export interface CursorAccountUsageReadResult { |
| 21 | 33 | readonly accountKey: string | null; |
| 22 | 34 | readonly records: readonly UsageRecord[]; |
| ... | ... | @@ -232,6 +244,7 @@ export async function readCursorAccountUsage( |
| 232 | 244 | provider: "cursor", |
| 233 | 245 | timestampMs, |
| 234 | 246 | model: event.model, |
| 247 | rateModel: cursorRateModel(event.model), | |
| 235 | 248 | sessionId, |
| 236 | 249 | totals, |
| 237 | 250 | reportedCostUsd, |
apps/server/src/usage/usagePricing.test.ts+21| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; | |
| 3 | 4 | import { |
| 4 | 5 | cacheSavingsUsd, |
| 5 | 6 | createOverrideRateTable, |
| ... | ... | @@ -49,6 +50,26 @@ describe("usage pricing", () => { |
| 49 | 50 | expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); |
| 50 | 51 | }); |
| 51 | 52 | |
| 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 | 73 | it("prices unknown models offline and uses input prices for omitted cache rates", () => { |
| 53 | 74 | const table = parseRateTable({}); |
| 54 | 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 | 184 | } |
| 185 | 185 | |
| 186 | 186 | /** The parts of a transcript record that decide its price. */ |
| 187 | export type PricedRecord = Pick<UsageRecord, "model" | "totals" | "fast" | "reportedCostUsd">; | |
| 187 | export type PricedRecord = Pick< | |
| 188 | UsageRecord, | |
| 189 | "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" | |
| 190 | >; | |
| 188 | 191 | |
| 189 | 192 | export interface PricedUsage { |
| 190 | 193 | readonly costUsd: number; |
| ... | ... | @@ -208,7 +211,7 @@ export function priceUsage( |
| 208 | 211 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; |
| 209 | 212 | } |
| 210 | 213 | |
| 211 | const rate = override ?? lookupRate(table, model); | |
| 214 | const rate = override ?? lookupRate(table, record.rateModel ?? model); | |
| 212 | 215 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; |
| 213 | 216 | |
| 214 | 217 | const standardCostUsd = |
| ... | ... | @@ -232,7 +235,8 @@ export function cacheSavingsUsd( |
| 232 | 235 | record: PricedRecord, |
| 233 | 236 | overrides?: RateTable, |
| 234 | 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 | 240 | if (rate === null) return 0; |
| 237 | 241 | return ( |
| 238 | 242 | record.totals.cachedInputTokens * |
apps/server/src/usage/usageTranscripts.ts+5| ... | ... | @@ -12,6 +12,11 @@ export interface UsageRecord { |
| 12 | 12 | readonly provider: UsageProviderKind; |
| 13 | 13 | readonly timestampMs: number; |
| 14 | 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 | 20 | readonly sessionId: string; |
| 16 | 21 | readonly totals: UsageTokenTotals; |
| 17 | 22 | readonly reportedCostUsd: number | null; |