authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 00:58:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 00:58:13-07:00
loge3e7cc3fc2e02966efe4efc45216dadcd046bfbb
tree54cf656522a8a77a294015fb80ca33fe3caecec0
parent86054b6df2c5e38b6e27b980eae2716bfe1da202
signature Signed by PGP key B5690EEEBB952194

fix(usage): price Claude fast-mode requests at the fast rate (#13599)

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 }
163163
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 );
171165
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 });
2531
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 });
3642
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 });
4551
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 });
5157
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 });
5864
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 });
8490
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 usagePricing8 * @module usagePricing
9 */9 */
10import type {10import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";
11 UsageCostSource,11
12 UsageModelPriceOverride,12import type { UsageRecord } from "./usageTranscripts.ts";
13 UsageTokenTotals,
14} from "@t3tools/contracts";
1513
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}
3033
31export type RateTable = ReadonlyMap<string, ModelRate>;34export type RateTable = ReadonlyMap<string, ModelRate>;
3235
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 */
34export function createOverrideRateTable(40export 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}
5967
60function finiteNumber(value: unknown): number | null {68function 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}
6371
72/** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */
73function 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 }
97114
...@@ -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.cacheCreationCostPerToken139 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken &&
140 a.fastMultiplier === b.fastMultiplier
123 );141 );
124}142}
125143
...@@ -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}
167185
186/** The parts of a transcript record that decide its price. */
187export type PricedRecord = Pick<UsageRecord, "model" | "totals" | "fast" | "reportedCostUsd">;
188
168export interface PricedUsage {189export interface PricedUsage {
169 readonly costUsd: number;190 readonly costUsd: number;
170 readonly costSource: UsageCostSource;191 readonly costSource: UsageCostSource;
171}192}
172193
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 already197 * `reasoningTokens` is intentionally not charged separately: it is already
177 * counted inside `outputTokens`.198 * counted inside `outputTokens`.
178 */199 */
179export function priceUsage(200export 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" };
193213
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;
199219
200 return { costUsd, costSource: "modelPriced" };220 return {
221 costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1),
222 costSource: "modelPriced",
223 };
201}224}
202225
203/**226/**
...@@ -206,11 +229,14 @@ export function priceUsage(...@@ -206,11 +229,14 @@ export function priceUsage(
206 */229 */
207export function cacheSavingsUsd(230export 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[]][])
57describe("scan cache round trip", () => {58describe("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 });
125130
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 };
129145
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 file24// 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.
26const USAGE_SCAN_CACHE_VERSION = 3 as const;26// v4: records carry Claude fast mode, which v3 rows never captured.
27const USAGE_SCAN_CACHE_VERSION = 4 as const;
2728
28export interface CachedFile {29export 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];
6264
63interface SerializedFile {65interface 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 ];
113116
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;
181185
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 });
5566
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 inherently24 * 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 this314 // 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 }