| 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,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 | } |
| 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 | export interface CursorAccountUsageReadResult { | 32 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; | ||
| 3 | import { | 4 | import { |
| 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 | }); |
| 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 | 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 | } |
| 185 | 185 | ||
| 186 | /** The parts of a transcript record that decide its price. */ | 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 | export interface PricedUsage { | 192 | export 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 | } |
| 210 | 213 | ||
| 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" }; |
| 213 | 216 | ||
| 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; |