| author | |
| committer | |
| log | e3e7cc3fc2e02966efe4efc45216dadcd046bfbb |
| tree | 54cf656522a8a77a294015fb80ca33fe3caecec0 |
| parent | 86054b6df2c5e38b6e27b980eae2716bfe1da202 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>8 files changed, 129 insertions(+), 38 deletions(-)
apps/server/src/usage/usageAggregation.test.ts+2| ... | @@ -12,6 +12,7 @@ const rates: RateTable = new Map([ | ... | @@ -12,6 +12,7 @@ const rates: RateTable = new Map([ |
| 12 | outputCostPerToken: 5e-5, | 12 | outputCostPerToken: 5e-5, |
| 13 | cacheReadCostPerToken: 1e-6, | 13 | cacheReadCostPerToken: 1e-6, |
| 14 | cacheCreationCostPerToken: 1.25e-5, | 14 | cacheCreationCostPerToken: 1.25e-5, |
| 15 | fastMultiplier: 1, | ||
| 15 | }, | 16 | }, |
| 16 | ], | 17 | ], |
| 17 | ]); | 18 | ]); |
| ... | @@ -31,6 +32,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord { | ... | @@ -31,6 +32,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord { |
| 31 | reasoningTokens: 0, | 32 | reasoningTokens: 0, |
| 32 | }, | 33 | }, |
| 33 | reportedCostUsd: null, | 34 | reportedCostUsd: null, |
| 35 | fast: false, | ||
| 34 | dedupeKey: null, | 36 | dedupeKey: null, |
| 35 | ...overrides, | 37 | ...overrides, |
| 36 | }; | 38 | }; |
apps/server/src/usage/usageAggregation.ts+2-9| ... | @@ -161,20 +161,13 @@ export class UsageAggregator { | ... | @@ -161,20 +161,13 @@ export class UsageAggregator { |
| 161 | this.#buckets.set(key, bucket); | 161 | this.#buckets.set(key, bucket); |
| 162 | } | 162 | } |
| 163 | 163 | ||
| 164 | const priced = priceUsage( | 164 | const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); |
| 165 | this.#options.rates, | ||
| 166 | record.model, | ||
| 167 | record.totals, | ||
| 168 | record.reportedCostUsd, | ||
| 169 | this.#options.priceOverrides, | ||
| 170 | ); | ||
| 171 | 165 | ||
| 172 | bucket.totals = addTotals(bucket.totals, record.totals); | 166 | bucket.totals = addTotals(bucket.totals, record.totals); |
| 173 | bucket.costUsd += priced.costUsd; | 167 | bucket.costUsd += priced.costUsd; |
| 174 | bucket.cacheSavingsUsd += cacheSavingsUsd( | 168 | bucket.cacheSavingsUsd += cacheSavingsUsd( |
| 175 | this.#options.rates, | 169 | this.#options.rates, |
| 176 | record.model, | 170 | record, |
| 177 | record.totals, | ||
| 178 | this.#options.priceOverrides, | 171 | this.#options.priceOverrides, |
| 179 | ); | 172 | ); |
| 180 | bucket.records += 1; | 173 | bucket.records += 1; |
apps/server/src/usage/usagePricing.test.ts+35-7| ... | @@ -22,6 +22,12 @@ describe("usage pricing", () => { | ... | @@ -22,6 +22,12 @@ describe("usage pricing", () => { |
| 22 | outputTokens: 1_000_000, | 22 | outputTokens: 1_000_000, |
| 23 | reasoningTokens: 500_000, | 23 | reasoningTokens: 500_000, |
| 24 | }; | 24 | }; |
| 25 | const record = (model: string, reportedCostUsd: number | null = null, fast = false) => ({ | ||
| 26 | model, | ||
| 27 | totals, | ||
| 28 | reportedCostUsd, | ||
| 29 | fast, | ||
| 30 | }); | ||
| 25 | 31 | ||
| 26 | it("uses custom token rates ahead of public and provider-reported costs", () => { | 32 | it("uses custom token rates ahead of public and provider-reported costs", () => { |
| 27 | const table = parseRateTable({ "example-model": rate(1) }); | 33 | const table = parseRateTable({ "example-model": rate(1) }); |
| ... | @@ -35,12 +41,12 @@ describe("usage pricing", () => { | ... | @@ -35,12 +41,12 @@ describe("usage pricing", () => { |
| 35 | }); | 41 | }); |
| 36 | 42 | ||
| 37 | for (const reportedCostUsd of [null, 99]) { | 43 | for (const reportedCostUsd of [null, 99]) { |
| 38 | expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({ | 44 | expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toEqual({ |
| 39 | costUsd: 13.5, | 45 | costUsd: 13.5, |
| 40 | costSource: "modelPriced", | 46 | costSource: "modelPriced", |
| 41 | }); | 47 | }); |
| 42 | } | 48 | } |
| 43 | expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5); | 49 | expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); |
| 44 | }); | 50 | }); |
| 45 | 51 | ||
| 46 | it("prices unknown models offline and uses input prices for omitted cache rates", () => { | 52 | it("prices unknown models offline and uses input prices for omitted cache rates", () => { |
| ... | @@ -49,11 +55,11 @@ describe("usage pricing", () => { | ... | @@ -49,11 +55,11 @@ describe("usage pricing", () => { |
| 49 | "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, | 55 | "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, |
| 50 | }); | 56 | }); |
| 51 | 57 | ||
| 52 | expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({ | 58 | expect(priceUsage(table, record("example-model"), overrides)).toEqual({ |
| 53 | costUsd: 14, | 59 | costUsd: 14, |
| 54 | costSource: "modelPriced", | 60 | costSource: "modelPriced", |
| 55 | }); | 61 | }); |
| 56 | expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0); | 62 | expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(0); |
| 57 | }); | 63 | }); |
| 58 | 64 | ||
| 59 | it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { | 65 | it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { |
| ... | @@ -64,7 +70,7 @@ describe("usage pricing", () => { | ... | @@ -64,7 +70,7 @@ describe("usage pricing", () => { |
| 64 | outputCostPerMillionTokens: 0, | 70 | outputCostPerMillionTokens: 0, |
| 65 | }, | 71 | }, |
| 66 | }); | 72 | }); |
| 67 | expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({ | 73 | expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({ |
| 68 | costUsd: 0, | 74 | costUsd: 0, |
| 69 | costSource: "modelPriced", | 75 | costSource: "modelPriced", |
| 70 | }); | 76 | }); |
| ... | @@ -74,14 +80,36 @@ describe("usage pricing", () => { | ... | @@ -74,14 +80,36 @@ describe("usage pricing", () => { |
| 74 | "vendor/Example-model[1m]", | 80 | "vendor/Example-model[1m]", |
| 75 | "other/example-model[1m]", | 81 | "other/example-model[1m]", |
| 76 | ]) { | 82 | ]) { |
| 77 | expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced"); | 83 | expect(priceUsage(table, record(model), overrides).costSource).toBe("unpriced"); |
| 78 | expect(priceUsage(table, model, totals, 99, overrides)).toEqual({ | 84 | expect(priceUsage(table, record(model, 99), overrides)).toEqual({ |
| 79 | costUsd: 99, | 85 | costUsd: 99, |
| 80 | costSource: "providerReported", | 86 | costSource: "providerReported", |
| 81 | }); | 87 | }); |
| 82 | } | 88 | } |
| 83 | }); | 89 | }); |
| 84 | 90 | ||
| 91 | it("prices fast-mode requests at the model's published fast multiple", () => { | ||
| 92 | const table = parseRateTable({ | ||
| 93 | "claude-opus-5-5": { ...rate(4e-6, 2e-7), provider_specific_entry: { fast: 2, us: 1.1 } }, | ||
| 94 | "claude-fable-5-1": { ...rate(1e-5, 2.5e-7), provider_specific_entry: { us: 1.1 } }, | ||
| 95 | }); | ||
| 96 | const overrides = createOverrideRateTable({ | ||
| 97 | "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 }, | ||
| 98 | }); | ||
| 99 | const cost = (model: string, fast: boolean, custom?: typeof overrides) => | ||
| 100 | priceUsage(table, record(model, null, fast), custom).costUsd; | ||
| 101 | |||
| 102 | expect(cost("claude-opus-5-5", true)).toBeCloseTo(2 * cost("claude-opus-5-5", false)); | ||
| 103 | expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo( | ||
| 104 | 2 * cacheSavingsUsd(table, record("claude-opus-5-5")), | ||
| 105 | ); | ||
| 106 | // No published fast tier, and custom prices, both stay at the standard rate. | ||
| 107 | expect(cost("claude-fable-5-1", true)).toBe(cost("claude-fable-5-1", false)); | ||
| 108 | expect(cost("claude-opus-5-5", true, overrides)).toBe( | ||
| 109 | cost("claude-opus-5-5", false, overrides), | ||
| 110 | ); | ||
| 111 | }); | ||
| 112 | |||
| 85 | it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { | 113 | it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { |
| 86 | const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; | 114 | const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; |
| 87 | const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; | 115 | const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; |
apps/server/src/usage/usagePricing.ts+43-17| ... | @@ -7,11 +7,9 @@ | ... | @@ -7,11 +7,9 @@ |
| 7 | * | 7 | * |
| 8 | * @module usagePricing | 8 | * @module usagePricing |
| 9 | */ | 9 | */ |
| 10 | import type { | 10 | import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; |
| 11 | UsageCostSource, | 11 | |
| 12 | UsageModelPriceOverride, | 12 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| 13 | UsageTokenTotals, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | 13 | ||
| 16 | /** | 14 | /** |
| 17 | * The subset of a LiteLLM entry we price against. All values are USD per token. | 15 | * The subset of a LiteLLM entry we price against. All values are USD per token. |
| ... | @@ -26,11 +24,19 @@ export interface ModelRate { | ... | @@ -26,11 +24,19 @@ export interface ModelRate { |
| 26 | readonly outputCostPerToken: number; | 24 | readonly outputCostPerToken: number; |
| 27 | readonly cacheReadCostPerToken: number; | 25 | readonly cacheReadCostPerToken: number; |
| 28 | readonly cacheCreationCostPerToken: number; | 26 | readonly cacheCreationCostPerToken: number; |
| 27 | /** | ||
| 28 | * Multiple of the rates above billed for a fast-mode request, from LiteLLM's | ||
| 29 | * `provider_specific_entry.fast`. `1` when the model publishes no fast tier. | ||
| 30 | */ | ||
| 31 | readonly fastMultiplier: number; | ||
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | export type RateTable = ReadonlyMap<string, ModelRate>; | 34 | export type RateTable = ReadonlyMap<string, ModelRate>; |
| 32 | 35 | ||
| 33 | /** Custom IDs keep their case, provider prefix, and variant suffix. */ | 36 | /** |
| 37 | * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates | ||
| 38 | * apply as entered, fast-mode requests included. | ||
| 39 | */ | ||
| 34 | export function createOverrideRateTable( | 40 | export function createOverrideRateTable( |
| 35 | overrides: Readonly<Record<string, UsageModelPriceOverride>>, | 41 | overrides: Readonly<Record<string, UsageModelPriceOverride>>, |
| 36 | ): RateTable { | 42 | ): RateTable { |
| ... | @@ -44,6 +50,7 @@ export function createOverrideRateTable( | ... | @@ -44,6 +50,7 @@ export function createOverrideRateTable( |
| 44 | (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, | 50 | (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, |
| 45 | cacheCreationCostPerToken: | 51 | cacheCreationCostPerToken: |
| 46 | (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, | 52 | (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, |
| 53 | fastMultiplier: 1, | ||
| 47 | }, | 54 | }, |
| 48 | ]), | 55 | ]), |
| 49 | ); | 56 | ); |
| ... | @@ -55,12 +62,21 @@ interface LiteLlmEntry { | ... | @@ -55,12 +62,21 @@ interface LiteLlmEntry { |
| 55 | readonly output_cost_per_token?: unknown; | 62 | readonly output_cost_per_token?: unknown; |
| 56 | readonly cache_read_input_token_cost?: unknown; | 63 | readonly cache_read_input_token_cost?: unknown; |
| 57 | readonly cache_creation_input_token_cost?: unknown; | 64 | readonly cache_creation_input_token_cost?: unknown; |
| 65 | readonly provider_specific_entry?: unknown; | ||
| 58 | } | 66 | } |
| 59 | 67 | ||
| 60 | function finiteNumber(value: unknown): number | null { | 68 | function finiteNumber(value: unknown): number | null { |
| 61 | return typeof value === "number" && Number.isFinite(value) ? value : null; | 69 | return typeof value === "number" && Number.isFinite(value) ? value : null; |
| 62 | } | 70 | } |
| 63 | 71 | ||
| 72 | /** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */ | ||
| 73 | function fastMultiplier(entry: LiteLlmEntry): number { | ||
| 74 | const specific = entry.provider_specific_entry; | ||
| 75 | if (typeof specific !== "object" || specific === null) return 1; | ||
| 76 | const fast = finiteNumber((specific as Record<string, unknown>)["fast"]); | ||
| 77 | return fast !== null && fast > 0 ? fast : 1; | ||
| 78 | } | ||
| 79 | |||
| 64 | /** | 80 | /** |
| 65 | * Projects the LiteLLM document into a rate table. | 81 | * Projects the LiteLLM document into a rate table. |
| 66 | * | 82 | * |
| ... | @@ -92,6 +108,7 @@ export function parseRateTable(document: unknown): RateTable { | ... | @@ -92,6 +108,7 @@ export function parseRateTable(document: unknown): RateTable { |
| 92 | // input rather than as free. | 108 | // input rather than as free. |
| 93 | cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, | 109 | cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, |
| 94 | cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, | 110 | cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, |
| 111 | fastMultiplier: fastMultiplier(entry), | ||
| 95 | }); | 112 | }); |
| 96 | } | 113 | } |
| 97 | 114 | ||
| ... | @@ -119,7 +136,8 @@ function sameRate(a: ModelRate, b: ModelRate): boolean { | ... | @@ -119,7 +136,8 @@ function sameRate(a: ModelRate, b: ModelRate): boolean { |
| 119 | a.inputCostPerToken === b.inputCostPerToken && | 136 | a.inputCostPerToken === b.inputCostPerToken && |
| 120 | a.outputCostPerToken === b.outputCostPerToken && | 137 | a.outputCostPerToken === b.outputCostPerToken && |
| 121 | a.cacheReadCostPerToken === b.cacheReadCostPerToken && | 138 | a.cacheReadCostPerToken === b.cacheReadCostPerToken && |
| 122 | a.cacheCreationCostPerToken === b.cacheCreationCostPerToken | 139 | a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && |
| 140 | a.fastMultiplier === b.fastMultiplier | ||
| 123 | ); | 141 | ); |
| 124 | } | 142 | } |
| 125 | 143 | ||
| ... | @@ -165,24 +183,26 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { | ... | @@ -165,24 +183,26 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { |
| 165 | return table.get(key) ?? null; | 183 | return table.get(key) ?? null; |
| 166 | } | 184 | } |
| 167 | 185 | ||
| 186 | /** The parts of a transcript record that decide its price. */ | ||
| 187 | export type PricedRecord = Pick<UsageRecord, "model" | "totals" | "fast" | "reportedCostUsd">; | ||
| 188 | |||
| 168 | export interface PricedUsage { | 189 | export interface PricedUsage { |
| 169 | readonly costUsd: number; | 190 | readonly costUsd: number; |
| 170 | readonly costSource: UsageCostSource; | 191 | readonly costSource: UsageCostSource; |
| 171 | } | 192 | } |
| 172 | 193 | ||
| 173 | /** | 194 | /** |
| 174 | * Prices a bucket's tokens. | 195 | * Prices one record's tokens. |
| 175 | * | 196 | * |
| 176 | * `reasoningTokens` is intentionally not charged separately: it is already | 197 | * `reasoningTokens` is intentionally not charged separately: it is already |
| 177 | * counted inside `outputTokens`. | 198 | * counted inside `outputTokens`. |
| 178 | */ | 199 | */ |
| 179 | export function priceUsage( | 200 | export function priceUsage( |
| 180 | table: RateTable, | 201 | table: RateTable, |
| 181 | model: string, | 202 | record: PricedRecord, |
| 182 | totals: UsageTokenTotals, | ||
| 183 | reportedCostUsd: number | null, | ||
| 184 | overrides?: RateTable, | 203 | overrides?: RateTable, |
| 185 | ): PricedUsage { | 204 | ): PricedUsage { |
| 205 | const { model, totals, reportedCostUsd } = record; | ||
| 186 | const override = overrides?.get(model.trim()); | 206 | const override = overrides?.get(model.trim()); |
| 187 | if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { | 207 | if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { |
| 188 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; | 208 | return { costUsd: reportedCostUsd, costSource: "providerReported" }; |
| ... | @@ -191,13 +211,16 @@ export function priceUsage( | ... | @@ -191,13 +211,16 @@ export function priceUsage( |
| 191 | const rate = override ?? lookupRate(table, model); | 211 | const rate = override ?? lookupRate(table, model); |
| 192 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; | 212 | if (rate === null) return { costUsd: 0, costSource: "unpriced" }; |
| 193 | 213 | ||
| 194 | const costUsd = | 214 | const standardCostUsd = |
| 195 | totals.uncachedInputTokens * rate.inputCostPerToken + | 215 | totals.uncachedInputTokens * rate.inputCostPerToken + |
| 196 | totals.cachedInputTokens * rate.cacheReadCostPerToken + | 216 | totals.cachedInputTokens * rate.cacheReadCostPerToken + |
| 197 | totals.cacheCreationTokens * rate.cacheCreationCostPerToken + | 217 | totals.cacheCreationTokens * rate.cacheCreationCostPerToken + |
| 198 | totals.outputTokens * rate.outputCostPerToken; | 218 | totals.outputTokens * rate.outputCostPerToken; |
| 199 | 219 | ||
| 200 | return { costUsd, costSource: "modelPriced" }; | 220 | return { |
| 221 | costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1), | ||
| 222 | costSource: "modelPriced", | ||
| 223 | }; | ||
| 201 | } | 224 | } |
| 202 | 225 | ||
| 203 | /** | 226 | /** |
| ... | @@ -206,11 +229,14 @@ export function priceUsage( | ... | @@ -206,11 +229,14 @@ export function priceUsage( |
| 206 | */ | 229 | */ |
| 207 | export function cacheSavingsUsd( | 230 | export function cacheSavingsUsd( |
| 208 | table: RateTable, | 231 | table: RateTable, |
| 209 | model: string, | 232 | record: PricedRecord, |
| 210 | totals: UsageTokenTotals, | ||
| 211 | overrides?: RateTable, | 233 | overrides?: RateTable, |
| 212 | ): number { | 234 | ): number { |
| 213 | const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); | 235 | const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.model); |
| 214 | if (rate === null) return 0; | 236 | if (rate === null) return 0; |
| 215 | return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); | 237 | return ( |
| 238 | record.totals.cachedInputTokens * | ||
| 239 | (rate.inputCostPerToken - rate.cacheReadCostPerToken) * | ||
| 240 | (record.fast ? rate.fastMultiplier : 1) | ||
| 241 | ); | ||
| 216 | } | 242 | } |
apps/server/src/usage/usageScanCache.test.ts+18-2| ... | @@ -24,6 +24,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord { | ... | @@ -24,6 +24,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord { |
| 24 | reasoningTokens: 0, | 24 | reasoningTokens: 0, |
| 25 | }, | 25 | }, |
| 26 | reportedCostUsd: null, | 26 | reportedCostUsd: null, |
| 27 | fast: false, | ||
| 27 | dedupeKey: "msg_1:", | 28 | dedupeKey: "msg_1:", |
| 28 | ...overrides, | 29 | ...overrides, |
| 29 | }; | 30 | }; |
| ... | @@ -57,7 +58,11 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) | ... | @@ -57,7 +58,11 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) |
| 57 | describe("scan cache round trip", () => { | 58 | describe("scan cache round trip", () => { |
| 58 | it("restores records unchanged", () => { | 59 | it("restores records unchanged", () => { |
| 59 | const original = cacheWith([ | 60 | const original = cacheWith([ |
| 60 | ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], | 61 | [ |
| 62 | "/a.jsonl", | ||
| 63 | 100, | ||
| 64 | [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", fast: true })], | ||
| 65 | ], | ||
| 61 | ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], | 66 | ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], |
| 62 | ]); | 67 | ]); |
| 63 | original.set("/grok.jsonl", { | 68 | original.set("/grok.jsonl", { |
| ... | @@ -123,9 +128,20 @@ describe("scan cache round trip", () => { | ... | @@ -123,9 +128,20 @@ describe("scan cache round trip", () => { |
| 123 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); | 128 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); |
| 124 | }); | 129 | }); |
| 125 | 130 | ||
| 131 | it("drops an entry whose fast flag is not 0 or 1", () => { | ||
| 132 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]])); | ||
| 133 | const row = encoded.files["/a.jsonl"]!.r[0]!; | ||
| 134 | const poisoned = { | ||
| 135 | ...encoded, | ||
| 136 | files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, | ||
| 137 | }; | ||
| 138 | |||
| 139 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); | ||
| 140 | }); | ||
| 141 | |||
| 126 | it("rejects a document from the previous cache version", () => { | 142 | it("rejects a document from the previous cache version", () => { |
| 127 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); | 143 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); |
| 128 | const previous = { ...encoded, version: 2 }; | 144 | const previous = { ...encoded, version: 3 }; |
| 129 | 145 | ||
| 130 | expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); | 146 | expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); |
| 131 | }); | 147 | }); |
apps/server/src/usage/usageScanCache.ts+9-3| ... | @@ -23,7 +23,8 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; | ... | @@ -23,7 +23,8 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; |
| 23 | // entries would keep serving double-counted records forever. | 23 | // entries would keep serving double-counted records forever. |
| 24 | // v3: entries carry the parse position and reducer state so a grown file | 24 | // v3: entries carry the parse position and reducer state so a grown file |
| 25 | // re-parses only its appended bytes instead of starting over. | 25 | // re-parses only its appended bytes instead of starting over. |
| 26 | const USAGE_SCAN_CACHE_VERSION = 3 as const; | 26 | // v4: records carry Claude fast mode, which v3 rows never captured. |
| 27 | const USAGE_SCAN_CACHE_VERSION = 4 as const; | ||
| 27 | 28 | ||
| 28 | export interface CachedFile { | 29 | export interface CachedFile { |
| 29 | readonly size: number; | 30 | readonly size: number; |
| ... | @@ -58,6 +59,7 @@ type SerializedRecord = readonly [ | ... | @@ -58,6 +59,7 @@ type SerializedRecord = readonly [ |
| 58 | reasoningTokens: number, | 59 | reasoningTokens: number, |
| 59 | dedupeKey: string | null, | 60 | dedupeKey: string | null, |
| 60 | reportedCostUsd: number | null, | 61 | reportedCostUsd: number | null, |
| 62 | fast: 0 | 1, | ||
| 61 | ]; | 63 | ]; |
| 62 | 64 | ||
| 63 | interface SerializedFile { | 65 | interface SerializedFile { |
| ... | @@ -109,6 +111,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { | ... | @@ -109,6 +111,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { |
| 109 | record.totals.reasoningTokens, | 111 | record.totals.reasoningTokens, |
| 110 | record.dedupeKey, | 112 | record.dedupeKey, |
| 111 | record.reportedCostUsd, | 113 | record.reportedCostUsd, |
| 114 | record.fast ? 1 : 0, | ||
| 112 | ]; | 115 | ]; |
| 113 | 116 | ||
| 114 | const files: Record<string, SerializedFile> = {}; | 117 | const files: Record<string, SerializedFile> = {}; |
| ... | @@ -165,7 +168,7 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -165,7 +168,7 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 165 | ): UsageRecord[] | null => { | 168 | ): UsageRecord[] | null => { |
| 166 | const records: UsageRecord[] = []; | 169 | const records: UsageRecord[] = []; |
| 167 | for (const row of rows) { | 170 | for (const row of rows) { |
| 168 | if (!isRecordArray(row) || row.length < 10) return null; | 171 | if (!isRecordArray(row) || row.length < 11) return null; |
| 169 | const [ | 172 | const [ |
| 170 | timestampMs, | 173 | timestampMs, |
| 171 | modelIndex, | 174 | modelIndex, |
| ... | @@ -177,6 +180,7 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -177,6 +180,7 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 177 | reasoning, | 180 | reasoning, |
| 178 | dedupeKey, | 181 | dedupeKey, |
| 179 | reportedCostUsd, | 182 | reportedCostUsd, |
| 183 | fast, | ||
| 180 | ] = row as SerializedRecord; | 184 | ] = row as SerializedRecord; |
| 181 | 185 | ||
| 182 | const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; | 186 | const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; |
| ... | @@ -188,7 +192,8 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -188,7 +192,8 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 188 | !Number.isFinite(cached) || | 192 | !Number.isFinite(cached) || |
| 189 | !Number.isFinite(cacheCreation) || | 193 | !Number.isFinite(cacheCreation) || |
| 190 | !Number.isFinite(output) || | 194 | !Number.isFinite(output) || |
| 191 | !Number.isFinite(reasoning) | 195 | !Number.isFinite(reasoning) || |
| 196 | (fast !== 0 && fast !== 1) | ||
| 192 | ) { | 197 | ) { |
| 193 | return null; | 198 | return null; |
| 194 | } | 199 | } |
| ... | @@ -206,6 +211,7 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -206,6 +211,7 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 206 | reasoningTokens: reasoning, | 211 | reasoningTokens: reasoning, |
| 207 | }, | 212 | }, |
| 208 | reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, | 213 | reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, |
| 214 | fast: fast === 1, | ||
| 209 | dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, | 215 | dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, |
| 210 | }); | 216 | }); |
| 211 | } | 217 | } |
apps/server/src/usage/usageTranscripts.test.ts+11| ... | @@ -15,6 +15,7 @@ function claudeLine(overrides: { | ... | @@ -15,6 +15,7 @@ function claudeLine(overrides: { |
| 15 | contentType: string; | 15 | contentType: string; |
| 16 | model?: string; | 16 | model?: string; |
| 17 | outputTokens?: number; | 17 | outputTokens?: number; |
| 18 | speed?: string; | ||
| 18 | }): string { | 19 | }): string { |
| 19 | return JSON.stringify({ | 20 | return JSON.stringify({ |
| 20 | type: "assistant", | 21 | type: "assistant", |
| ... | @@ -31,6 +32,7 @@ function claudeLine(overrides: { | ... | @@ -31,6 +32,7 @@ function claudeLine(overrides: { |
| 31 | cache_creation_input_tokens: 66818, | 32 | cache_creation_input_tokens: 66818, |
| 32 | cache_read_input_tokens: 1000, | 33 | cache_read_input_tokens: 1000, |
| 33 | output_tokens: overrides.outputTokens ?? 286, | 34 | output_tokens: overrides.outputTokens ?? 286, |
| 35 | ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), | ||
| 34 | }, | 36 | }, |
| 35 | }, | 37 | }, |
| 36 | }); | 38 | }); |
| ... | @@ -51,6 +53,15 @@ describe("parseClaudeLine", () => { | ... | @@ -51,6 +53,15 @@ describe("parseClaudeLine", () => { |
| 51 | reasoningTokens: 0, | 53 | reasoningTokens: 0, |
| 52 | }); | 54 | }); |
| 53 | expect(record?.dedupeKey).toBe("msg_1:"); | 55 | expect(record?.dedupeKey).toBe("msg_1:"); |
| 56 | expect(record?.fast).toBe(false); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("marks fast-mode requests", () => { | ||
| 60 | const line = (speed: string) => | ||
| 61 | parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); | ||
| 62 | |||
| 63 | expect(line("fast")?.fast).toBe(true); | ||
| 64 | expect(line("standard")?.fast).toBe(false); | ||
| 54 | }); | 65 | }); |
| 55 | 66 | ||
| 56 | it("gives every content block of one message the same dedupe key", () => { | 67 | it("gives every content block of one message the same dedupe key", () => { |
apps/server/src/usage/usageTranscripts.ts+9| ... | @@ -15,6 +15,11 @@ export interface UsageRecord { | ... | @@ -15,6 +15,11 @@ export interface UsageRecord { |
| 15 | readonly sessionId: string; | 15 | readonly sessionId: string; |
| 16 | readonly totals: UsageTokenTotals; | 16 | readonly totals: UsageTokenTotals; |
| 17 | readonly reportedCostUsd: number | null; | 17 | readonly reportedCostUsd: number | null; |
| 18 | /** | ||
| 19 | * Whether the request ran in fast mode, which bills at a model-specific | ||
| 20 | * multiple of the standard rate. Only Claude Code records this. | ||
| 21 | */ | ||
| 22 | readonly fast: boolean; | ||
| 18 | /** | 23 | /** |
| 19 | * Key for cross-file de-duplication, or `null` when the record is inherently | 24 | * Key for cross-file de-duplication, or `null` when the record is inherently |
| 20 | * unique and needs no dedup. | 25 | * unique and needs no dedup. |
| ... | @@ -145,6 +150,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { | ... | @@ -145,6 +150,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { |
| 145 | reasoningTokens: 0, | 150 | reasoningTokens: 0, |
| 146 | }, | 151 | }, |
| 147 | reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, | 152 | reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, |
| 153 | fast: usageRecord["speed"] === "fast", | ||
| 148 | dedupeKey, | 154 | dedupeKey, |
| 149 | }; | 155 | }; |
| 150 | } | 156 | } |
| ... | @@ -304,6 +310,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | ... | @@ -304,6 +310,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord |
| 304 | totals, | 310 | totals, |
| 305 | // Codex does not report cost in the rollout. | 311 | // Codex does not report cost in the rollout. |
| 306 | reportedCostUsd: null, | 312 | reportedCostUsd: null, |
| 313 | fast: false, | ||
| 307 | // Events surviving the fork-copy suppression above are unique to this | 314 | // Events surviving the fork-copy suppression above are unique to this |
| 308 | // rollout, so they need no global dedup. | 315 | // rollout, so they need no global dedup. |
| 309 | dedupeKey: null, | 316 | dedupeKey: null, |
| ... | @@ -433,6 +440,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { | ... | @@ -433,6 +440,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { |
| 433 | sessionId, | 440 | sessionId, |
| 434 | totals: grokTotalsToUsage(topLevel), | 441 | totals: grokTotalsToUsage(topLevel), |
| 435 | reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), | 442 | reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), |
| 443 | fast: false, | ||
| 436 | // No prompt id means we cannot tell two same-second updates apart. | 444 | // No prompt id means we cannot tell two same-second updates apart. |
| 437 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, | 445 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, |
| 438 | }, | 446 | }, |
| ... | @@ -479,6 +487,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { | ... | @@ -479,6 +487,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { |
| 479 | sessionId, | 487 | sessionId, |
| 480 | totals, | 488 | totals, |
| 481 | reportedCostUsd, | 489 | reportedCostUsd, |
| 490 | fast: false, | ||
| 482 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, | 491 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, |
| 483 | }); | 492 | }); |
| 484 | } | 493 | } |