authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 02:01:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:01:12-07:00
log56914128c1ff9dcf6585c380b7b25845d6ca5f98
tree2550766bd43dee365c939b9c4e73a6a6f132395e
parent6108ef3d3d0ef2a598088a1bb64980580e935830
signature Signed by PGP key B5690EEEBB952194

fix(usage): Codex Fast and Ultrafast now cost what they bill (#15101)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

15 files changed, 399 insertions(+), 118 deletions(-)

apps/server/src/usage/UsageService.test.ts+93
......@@ -34,6 +34,7 @@ import * as UsageService from "./UsageService.ts";
3434
3535const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
3636const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
37const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));
3738
3839function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
3940 return `${JSON.stringify({
......@@ -788,6 +789,98 @@ describe("UsageService", () => {
788789 }).pipe(Effect.scoped),
789790 );
790791
792 it.live(
793 "upgrades a v4 cache: reprices live Codex tiers, keeps deleted rollouts, leaves v4 intact",
794 () =>
795 Effect.gen(function* () {
796 const { home, settings } = yield* setup;
797 const sessions = NodePath.join(home, "codex", "sessions");
798 const rollout = (sessionId: string, outputTokens: number) =>
799 [
800 { type: "session_meta", payload: { id: sessionId } },
801 { type: "turn_context", payload: { model: "gpt-6-astra" } },
802 {
803 type: "event_msg",
804 payload: {
805 type: "thread_settings_applied",
806 thread_settings: { service_tier: "ultrafast" },
807 },
808 },
809 {
810 type: "event_msg",
811 timestamp: "2026-08-01T10:00:00Z",
812 payload: {
813 type: "token_count",
814 info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } },
815 },
816 },
817 ]
818 .map((line) => encodeUnknownJsonString(line))
819 .join("\n") + "\n";
820 const live = NodePath.join(sessions, "live.jsonl");
821 const deleted = NodePath.join(sessions, "deleted.jsonl");
822 yield* Effect.promise(async () => {
823 await NodeFSP.mkdir(sessions, { recursive: true });
824 await NodeFSP.writeFile(live, rollout("live", 10));
825 await NodeFSP.writeFile(deleted, rollout("deleted", 20));
826 });
827
828 yield* Effect.gen(function* () {
829 const { stateDir } = yield* ServerConfig.ServerConfig;
830 const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json");
831 const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json");
832 yield* (yield* UsageService.make).readSummary(WINDOW);
833
834 // Rewrite the cache as a v4 server left it: every Codex record at
835 // speed 0 (standard), and no tier in the reducer state.
836 const legacy = yield* Effect.promise(async () => {
837 const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as {
838 files: Record<string, { r: unknown[][]; cs: { speed?: unknown } }>;
839 };
840 for (const file of Object.values(document.files)) {
841 file.r = file.r.map((row) => [...row.slice(0, 10), 0]);
842 delete file.cs.speed;
843 }
844 const text = encodeUnknownJsonString({ ...document, version: 4 });
845 await NodeFSP.writeFile(legacyPath, text);
846 await NodeFSP.rm(cachePath);
847 await NodeFSP.rm(deleted);
848 return text;
849 });
850
851 const summary = yield* (yield* UsageService.make).readSummary(WINDOW);
852 // The live rollout re-parses at the ultrafast rate (10 x 6); the
853 // deleted one keeps its saved v4 usage at the standard rate (20 x 1).
854 assert.strictEqual(totalOutputTokens(summary), 30);
855 assert.strictEqual(
856 summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0),
857 80,
858 );
859 // A v4 server sharing this state directory still finds its own cache.
860 assert.strictEqual(
861 yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")),
862 legacy,
863 );
864 }).pipe(
865 Effect.provide(
866 serviceLayers({
867 prefix: "usage-service-v4-upgrade-test",
868 home,
869 settings,
870 ratesDocument: {
871 "gpt-6-astra": {
872 input_cost_per_token: 0,
873 output_cost_per_token: 1,
874 input_cost_per_token_ultrafast: 0,
875 output_cost_per_token_ultrafast: 6,
876 },
877 },
878 }),
879 ),
880 );
881 }).pipe(Effect.scoped),
882 );
883
791884 it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () =>
792885 Effect.gen(function* () {
793886 const { transcript, settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+15-5
......@@ -64,7 +64,9 @@ import {
6464 decodeScanCache,
6565 dedupeWithinFile,
6666 encodeScanCache,
67 LEGACY_SCAN_CACHE_FILE_NAME,
6768 pruneScanCache,
69 SCAN_CACHE_FILE_NAME,
6870 type ScanCache,
6971} from "./usageScanCache.ts";
7072import type { UsageRecord } from "./usageTranscripts.ts";
......@@ -168,7 +170,8 @@ export const make = Effect.gen(function* () {
168170 };
169171
170172 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
171 const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
173 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
174 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
172175 let rates: RateTable = new Map();
173176 let ratesFetchedAtMs: number | null = null;
174177 let ratesStatus: UsagePricing["status"] = "unavailable";
......@@ -362,10 +365,17 @@ export const make = Effect.gen(function* () {
362365 */
363366 const ensureScanCacheLoaded = yield* Effect.cached(
364367 Effect.gen(function* () {
365 const document = yield* fileSystem.readFileString(scanCachePath).pipe(
366 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
367 Effect.catchCause(() => Effect.succeed(null)),
368 );
368 const readDocument = (filePath: string) =>
369 fileSystem.readFileString(filePath).pipe(
370 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
371 Effect.catchCause(() => Effect.succeed(null)),
372 );
373 let document = yield* readDocument(scanCachePath);
374 if (document === null) {
375 document = yield* readDocument(legacyScanCachePath);
376 // Write the migrated cache to its own file on the next scan.
377 cacheDirty = document !== null;
378 }
369379 if (document === null) return;
370380 for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry);
371381 const sources = decodeCachedSources(document);
apps/server/src/usage/antigravityUsageReader.ts+1-1
......@@ -240,7 +240,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise<Us
240240 "antigravity-unknown",
241241 totals,
242242 reportedCostUsd: null,
243 fast: false,
243 speed: "standard",
244244 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,
245245 };
246246 records.push({
apps/server/src/usage/cursorUsageReader.ts+1-1
......@@ -253,7 +253,7 @@ export async function readCursorAccountUsage(
253253 sessionId,
254254 totals,
255255 reportedCostUsd,
256 fast: false,
256 speed: "standard",
257257 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
258258 });
259259 }
apps/server/src/usage/opencodeUsageReader.ts+1-1
......@@ -64,7 +64,7 @@ function parseOpenCodeMessage(
6464 // OpenCode writes zero for models without a known rate, including paid
6565 // subscription models. Let the shared price table estimate those records.
6666 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null,
67 fast: false,
67 speed: "standard",
6868 dedupeKey: id ? `opencode:${id}` : null,
6969 };
7070}
apps/server/src/usage/usageAggregation.test.ts+3-2
......@@ -12,7 +12,8 @@ const rates: RateTable = new Map([
1212 outputCostPerToken: 5e-5,
1313 cacheReadCostPerToken: 1e-6,
1414 cacheCreationCostPerToken: 1.25e-5,
15 fastMultiplier: 1,
15 fast: null,
16 ultrafast: null,
1617 },
1718 ],
1819]);
......@@ -32,7 +33,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
3233 reasoningTokens: 0,
3334 },
3435 reportedCostUsd: null,
35 fast: false,
36 speed: "standard",
3637 dedupeKey: null,
3738 ...overrides,
3839 };
apps/server/src/usage/usagePricing.test.ts+40-9
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "@effect/vitest";
22
33import { cursorRateModel } from "./cursorUsageReader.ts";
4import type { UsageSpeed } from "./usageTranscripts.ts";
45import {
56 cacheSavingsUsd,
67 createOverrideRateTable,
......@@ -23,11 +24,15 @@ describe("usage pricing", () => {
2324 outputTokens: 1_000_000,
2425 reasoningTokens: 500_000,
2526 };
26 const record = (model: string, reportedCostUsd: number | null = null, fast = false) => ({
27 const record = (
28 model: string,
29 reportedCostUsd: number | null = null,
30 speed: UsageSpeed = "standard",
31 ) => ({
2732 model,
2833 totals,
2934 reportedCostUsd,
30 fast,
35 speed,
3136 });
3237
3338 it("uses custom token rates ahead of public and provider-reported costs", () => {
......@@ -117,20 +122,46 @@ describe("usage pricing", () => {
117122 const overrides = createOverrideRateTable({
118123 "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 },
119124 });
120 const cost = (model: string, fast: boolean, custom?: typeof overrides) =>
121 priceUsage(table, record(model, null, fast), custom).costUsd;
125 const cost = (model: string, speed: UsageSpeed, custom?: typeof overrides) =>
126 priceUsage(table, record(model, null, speed), custom).costUsd;
122127
123 expect(cost("claude-opus-5-5", true)).toBeCloseTo(2 * cost("claude-opus-5-5", false));
124 expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo(
128 expect(cost("claude-opus-5-5", "fast")).toBeCloseTo(2 * cost("claude-opus-5-5", "standard"));
129 expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, "fast"))).toBeCloseTo(
125130 2 * cacheSavingsUsd(table, record("claude-opus-5-5")),
126131 );
127132 // No published fast tier, and custom prices, both stay at the standard rate.
128 expect(cost("claude-fable-5-1", true)).toBe(cost("claude-fable-5-1", false));
129 expect(cost("claude-opus-5-5", true, overrides)).toBe(
130 cost("claude-opus-5-5", false, overrides),
133 expect(cost("claude-fable-5-1", "fast")).toBe(cost("claude-fable-5-1", "standard"));
134 expect(cost("claude-opus-5-5", "fast", overrides)).toBe(
135 cost("claude-opus-5-5", "standard", overrides),
131136 );
132137 });
133138
139 it("prices Codex priority and ultrafast requests at their published tier rates", () => {
140 const table = parseRateTable({
141 "gpt-6-astra": {
142 ...rate(1e-5, 1e-6),
143 input_cost_per_token_priority: 2e-5,
144 output_cost_per_token_priority: 1e-4,
145 cache_read_input_token_cost_priority: 2e-6,
146 input_cost_per_token_ultrafast: 6e-5,
147 output_cost_per_token_ultrafast: 3e-4,
148 // No ultrafast cache rate: keeps the standard 10:1 input-to-cache ratio.
149 },
150 "gpt-6-sol": rate(2e-6, 2e-7),
151 });
152 const cost = (model: string, speed: UsageSpeed) =>
153 priceUsage(table, record(model, null, speed)).costUsd;
154 const standard = cost("gpt-6-astra", "standard");
155
156 expect(cost("gpt-6-astra", "fast")).toBeCloseTo(2 * standard);
157 expect(cost("gpt-6-astra", "ultrafast")).toBeCloseTo(6 * standard);
158 expect(cacheSavingsUsd(table, record("gpt-6-astra", null, "ultrafast"))).toBeCloseTo(
159 6 * cacheSavingsUsd(table, record("gpt-6-astra")),
160 );
161 // A tier the model does not publish bills at the standard rate.
162 expect(cost("gpt-6-sol", "ultrafast")).toBe(cost("gpt-6-sol", "standard"));
163 });
164
134165 it("keeps the canonical Fable rate separate from DeepInfra in either order", () => {
135166 const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const;
136167 const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const;
apps/server/src/usage/usagePricing.ts+104-54
......@@ -9,33 +9,39 @@
99 */
1010import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";
1111
12import type { UsageRecord } from "./usageTranscripts.ts";
12import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
1313
14/**
15 * The subset of a LiteLLM entry we price against. All values are USD per token.
16 *
17 * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`,
18 * `*_priority`, `*_batches`). We deliberately price at the base tier: the
19 * transcripts don't record which tier served a request, so anything else would
20 * be a guess dressed up as precision.
21 */
22export interface ModelRate {
14/** Token rates for one billing speed. All values are USD per token. */
15export interface TokenRates {
2316 readonly inputCostPerToken: number;
2417 readonly outputCostPerToken: number;
2518 readonly cacheReadCostPerToken: number;
2619 readonly cacheCreationCostPerToken: number;
20}
21
22/**
23 * The subset of a LiteLLM entry we price against: standard rates, plus rates
24 * for each faster speed the model publishes. A request at a speed with no
25 * published rates bills at the standard rates.
26 *
27 * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches`
28 * variants. Transcripts don't record those, so we don't price them.
29 */
30export interface ModelRate extends TokenRates {
2731 /**
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.
32 * From LiteLLM's `provider_specific_entry.fast` multiple (Claude fast mode),
33 * or else its `*_priority` rates (Codex `priority`).
3034 */
31 readonly fastMultiplier: number;
35 readonly fast: TokenRates | null;
36 /** From LiteLLM's `*_ultrafast` rates (Codex `ultrafast`). */
37 readonly ultrafast: TokenRates | null;
3238}
3339
3440export type RateTable = ReadonlyMap<string, ModelRate>;
3541
3642/**
3743 * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates
38 * apply as entered, fast-mode requests included.
44 * apply as entered, at every speed.
3945 */
4046export function createOverrideRateTable(
4147 overrides: Readonly<Record<string, UsageModelPriceOverride>>,
......@@ -50,31 +56,68 @@ export function createOverrideRateTable(
5056 (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
5157 cacheCreationCostPerToken:
5258 (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
53 fastMultiplier: 1,
59 fast: null,
60 ultrafast: null,
5461 },
5562 ]),
5663 );
5764}
5865
59/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
60interface LiteLlmEntry {
61 readonly input_cost_per_token?: unknown;
62 readonly output_cost_per_token?: unknown;
63 readonly cache_read_input_token_cost?: unknown;
64 readonly cache_creation_input_token_cost?: unknown;
65 readonly provider_specific_entry?: unknown;
66}
66/** One raw LiteLLM entry. Field names carry a tier suffix, e.g. `_priority`. */
67type LiteLlmEntry = Readonly<Record<string, unknown>>;
6768
6869function finiteNumber(value: unknown): number | null {
6970 return typeof value === "number" && Number.isFinite(value) ? value : null;
7071}
7172
73/**
74 * Reads one rate set, `suffix` selecting a tier such as `_priority`. Returns
75 * `null` without both an input and an output rate.
76 *
77 * Anthropic bills cache reads at a discount and cache writes at a premium.
78 * When the standard tier omits them, cached input is priced as plain input
79 * rather than as free. A faster tier that omits them keeps the standard tier's
80 * cache-to-input ratio.
81 */
82function readTokenRates(
83 entry: LiteLlmEntry,
84 suffix: string,
85 standard?: TokenRates,
86): TokenRates | null {
87 const input = finiteNumber(entry[`input_cost_per_token${suffix}`]);
88 const output = finiteNumber(entry[`output_cost_per_token${suffix}`]);
89 if (input === null || output === null) return null;
90 const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") =>
91 finiteNumber(entry[`${name}${suffix}`]) ??
92 (standard !== undefined && standard.inputCostPerToken > 0
93 ? (standard[field] / standard.inputCostPerToken) * input
94 : input);
95 return {
96 inputCostPerToken: input,
97 outputCostPerToken: output,
98 cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"),
99 cacheCreationCostPerToken: cacheRate(
100 "cache_creation_input_token_cost",
101 "cacheCreationCostPerToken",
102 ),
103 };
104}
105
106function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates {
107 return {
108 inputCostPerToken: rates.inputCostPerToken * multiple,
109 outputCostPerToken: rates.outputCostPerToken * multiple,
110 cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple,
111 cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple,
112 };
113}
114
72115/** 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;
116function fastMultiplier(entry: LiteLlmEntry): number | null {
117 const specific = entry["provider_specific_entry"];
118 if (typeof specific !== "object" || specific === null) return null;
76119 const fast = finiteNumber((specific as Record<string, unknown>)["fast"]);
77 return fast !== null && fast > 0 ? fast : 1;
120 return fast !== null && fast > 0 ? fast : null;
78121}
79122
80123/**
......@@ -94,21 +137,19 @@ export function parseRateTable(document: unknown): RateTable {
94137 for (const [name, raw] of Object.entries(document as Record<string, unknown>)) {
95138 if (typeof raw !== "object" || raw === null) continue;
96139 const entry = raw as LiteLlmEntry;
97 const input = finiteNumber(entry.input_cost_per_token);
98 const output = finiteNumber(entry.output_cost_per_token);
99 if (input === null || output === null) continue;
140 const standard = readTokenRates(entry, "");
141 if (standard === null) continue;
100142
101143 const key = normalizeRateKey(name);
102144 if (key.length === 0) continue;
145 const multiple = fastMultiplier(entry);
103146 table.set(key, {
104 inputCostPerToken: input,
105 outputCostPerToken: output,
106 // Anthropic bills cache reads at a discount and cache writes at a
107 // premium. When a model omits them, cached input is priced as plain
108 // input rather than as free.
109 cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,
110 cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
111 fastMultiplier: fastMultiplier(entry),
147 ...standard,
148 fast:
149 multiple === null
150 ? readTokenRates(entry, "_priority", standard)
151 : scaleTokenRates(standard, multiple),
152 ultrafast: readTokenRates(entry, "_ultrafast", standard),
112153 });
113154 }
114155
......@@ -131,16 +172,29 @@ export function parseRateTable(document: unknown): RateTable {
131172 return table;
132173}
133174
134function sameRate(a: ModelRate, b: ModelRate): boolean {
175function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean {
176 if (a === null || b === null) return a === b;
135177 return (
136178 a.inputCostPerToken === b.inputCostPerToken &&
137179 a.outputCostPerToken === b.outputCostPerToken &&
138180 a.cacheReadCostPerToken === b.cacheReadCostPerToken &&
139 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken &&
140 a.fastMultiplier === b.fastMultiplier
181 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken
141182 );
142183}
143184
185function sameRate(a: ModelRate, b: ModelRate): boolean {
186 return (
187 sameTokenRates(a, b) &&
188 sameTokenRates(a.fast, b.fast) &&
189 sameTokenRates(a.ultrafast, b.ultrafast)
190 );
191}
192
193/** The rates a request at `speed` bills at. */
194function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates {
195 return (speed === "standard" ? null : rate[speed]) ?? rate;
196}
197
144198function normalizeRateKey(model: string): string {
145199 return model.trim().toLowerCase();
146200}
......@@ -186,7 +240,7 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null {
186240/** The parts of a transcript record that decide its price. */
187241export type PricedRecord = Pick<
188242 UsageRecord,
189 "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd"
243 "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd"
190244>;
191245
192246export interface PricedUsage {
......@@ -214,14 +268,13 @@ export function priceUsage(
214268 const rate = override ?? lookupRate(table, record.rateModel ?? model);
215269 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
216270
217 const standardCostUsd =
218 totals.uncachedInputTokens * rate.inputCostPerToken +
219 totals.cachedInputTokens * rate.cacheReadCostPerToken +
220 totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
221 totals.outputTokens * rate.outputCostPerToken;
222
271 const rates = ratesAt(rate, record.speed);
223272 return {
224 costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1),
273 costUsd:
274 totals.uncachedInputTokens * rates.inputCostPerToken +
275 totals.cachedInputTokens * rates.cacheReadCostPerToken +
276 totals.cacheCreationTokens * rates.cacheCreationCostPerToken +
277 totals.outputTokens * rates.outputCostPerToken,
225278 costSource: "modelPriced",
226279 };
227280}
......@@ -238,9 +291,6 @@ export function cacheSavingsUsd(
238291 const rate =
239292 overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model);
240293 if (rate === null) return 0;
241 return (
242 record.totals.cachedInputTokens *
243 (rate.inputCostPerToken - rate.cacheReadCostPerToken) *
244 (record.fast ? rate.fastMultiplier : 1)
245 );
294 const rates = ratesAt(rate, record.speed);
295 return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken);
246296}
apps/server/src/usage/usageScanCache.test.ts+10-7
......@@ -24,7 +24,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
2424 reasoningTokens: 0,
2525 },
2626 reportedCostUsd: null,
27 fast: false,
27 speed: "standard",
2828 dedupeKey: "msg_1:",
2929 ...overrides,
3030 };
......@@ -61,7 +61,7 @@ describe("scan cache round trip", () => {
6161 [
6262 "/a.jsonl",
6363 100,
64 [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", fast: true })],
64 [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })],
6565 ],
6666 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
6767 ]);
......@@ -79,11 +79,14 @@ describe("scan cache round trip", () => {
7979 size: 80,
8080 mtimeMs: 400,
8181 provider: "codex",
82 records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })],
82 records: [
83 record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null, speed: "ultrafast" }),
84 ],
8385 tailRecords: [],
8486 position: position({
8587 codexState: {
86 model: "gpt-5.2-codex",
88 model: "gpt-6-astra",
89 speed: "ultrafast",
8790 sessionId: "session-c",
8891 lastUsageSignature: '{"input_tokens":1}',
8992 sawSessionMeta: true,
......@@ -128,8 +131,8 @@ describe("scan cache round trip", () => {
128131 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);
129132 });
130133
131 it("drops an entry whose fast flag is not 0 or 1", () => {
132 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]]));
134 it("drops an entry whose speed is not a known index", () => {
135 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]]));
133136 const row = encoded.files["/a.jsonl"]!.r[0]!;
134137 const poisoned = {
135138 ...encoded,
......@@ -139,7 +142,7 @@ describe("scan cache round trip", () => {
139142 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);
140143 });
141144
142 it("rejects a document from the previous cache version", () => {
145 it("rejects a document from before records carried a speed", () => {
143146 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
144147 const previous = { ...encoded, version: 3 };
145148
apps/server/src/usage/usageScanCache.ts+46-16
......@@ -17,14 +17,33 @@
1717import type { UsageProviderKind } from "@t3tools/contracts";
1818
1919import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
20import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts";
20import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
2121
2222// v2: Codex fork-copy suppression changed what a file parses to, so v1
2323// entries would keep serving double-counted records forever.
2424// v3: entries carry the parse position and reducer state so a grown file
2525// re-parses only its appended bytes instead of starting over.
2626// v4: records carry Claude fast mode, which v3 rows never captured.
27const USAGE_SCAN_CACHE_VERSION = 4 as const;
27// v5: Codex records carry their service tier. v4 rows store speed the same
28// way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries.
29const USAGE_SCAN_CACHE_VERSION = 5 as const;
30const SPEED_COMPATIBLE_SINCE_VERSION = 4;
31
32/**
33 * Each cache version writes its own file in the state directory. An older
34 * server sharing that directory cannot read a newer cache and would replace
35 * it, dropping saved usage for deleted transcripts. Separate files keep both.
36 * A v5 server reads the legacy (v4) file once, when its own file is missing.
37 */
38export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json";
39export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json";
40
41/** Serialised as the index into this list. */
42const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"];
43
44function isSpeed(value: unknown): value is UsageSpeed {
45 return SPEEDS.some((speed) => speed === value);
46}
2847
2948export interface CachedFile {
3049 readonly size: number;
......@@ -59,7 +78,7 @@ type SerializedRecord = readonly [
5978 reasoningTokens: number,
6079 dedupeKey: string | null,
6180 reportedCostUsd: number | null,
62 fast: 0 | 1,
81 speed: number,
6382];
6483
6584interface SerializedFile {
......@@ -111,7 +130,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache {
111130 record.totals.reasoningTokens,
112131 record.dedupeKey,
113132 record.reportedCostUsd,
114 record.fast ? 1 : 0,
133 SPEEDS.indexOf(record.speed),
115134 ];
116135
117136 const files: Record<string, SerializedFile> = {};
......@@ -147,7 +166,14 @@ export function decodeScanCache(document: unknown): ScanCache {
147166 if (typeof document !== "object" || document === null) return cache;
148167
149168 const root = document as Partial<SerializedCache>;
150 if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache;
169 const version = root.version;
170 if (
171 typeof version !== "number" ||
172 version < SPEED_COMPATIBLE_SINCE_VERSION ||
173 version > USAGE_SCAN_CACHE_VERSION
174 ) {
175 return cache;
176 }
151177 if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache;
152178 if (typeof root.files !== "object" || root.files === null) return cache;
153179
......@@ -180,8 +206,9 @@ export function decodeScanCache(document: unknown): ScanCache {
180206 reasoning,
181207 dedupeKey,
182208 reportedCostUsd,
183 fast,
209 speedIndex,
184210 ] = row as SerializedRecord;
211 const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined;
185212
186213 const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
187214 if (
......@@ -193,7 +220,7 @@ export function decodeScanCache(document: unknown): ScanCache {
193220 !Number.isFinite(cacheCreation) ||
194221 !Number.isFinite(output) ||
195222 !Number.isFinite(reasoning) ||
196 (fast !== 0 && fast !== 1)
223 speed === undefined
197224 ) {
198225 return null;
199226 }
......@@ -211,7 +238,7 @@ export function decodeScanCache(document: unknown): ScanCache {
211238 reasoningTokens: reasoning,
212239 },
213240 reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
214 fast: fast === 1,
241 speed,
215242 dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
216243 });
217244 }
......@@ -242,7 +269,11 @@ export function decodeScanCache(document: unknown): ScanCache {
242269 ) {
243270 continue;
244271 }
245 const codexState = decodeCodexState(entry.cs);
272 // v4 Codex records predate service tiers, so they all priced as standard.
273 // Keep them, because the rollout may be gone, but make a live rollout
274 // re-parse whole: no file has size -1, and a zero position cannot resume.
275 const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION;
276 const codexState = legacyCodex ? null : decodeCodexState(entry.cs);
246277 if (codexState === undefined) continue;
247278
248279 const provider: UsageProviderKind = entry.p;
......@@ -251,17 +282,14 @@ export function decodeScanCache(document: unknown): ScanCache {
251282 if (records === null || tailRecords === null) continue;
252283
253284 cache.set(path, {
254 size: entry.s,
285 size: legacyCodex ? -1 : entry.s,
255286 mtimeMs: entry.m,
256287 provider,
257288 records,
258289 tailRecords,
259 position: {
260 resumeOffset: entry.o,
261 guardLength: entry.gl,
262 guardHash: entry.gh,
263 codexState,
264 },
290 position: legacyCodex
291 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null }
292 : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState },
265293 });
266294 }
267295
......@@ -279,6 +307,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {
279307 const state = value as Partial<CodexScanState>;
280308 if (
281309 typeof state.model !== "string" ||
310 !isSpeed(state.speed) ||
282311 typeof state.sessionId !== "string" ||
283312 (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||
284313 typeof state.sawSessionMeta !== "boolean" ||
......@@ -290,6 +319,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {
290319 }
291320 return {
292321 model: state.model,
322 speed: state.speed,
293323 sessionId: state.sessionId,
294324 lastUsageSignature: state.lastUsageSignature ?? null,
295325 sawSessionMeta: state.sawSessionMeta,
apps/server/src/usage/usageTranscriptReader.test.ts+16-3
......@@ -76,6 +76,14 @@ function codexModelLine(model: string): string {
7676 })}\n`;
7777}
7878
79function codexTierLine(serviceTier: string): string {
80 return `${JSON.stringify({
81 type: "event_msg",
82 timestamp: "2026-08-01T10:00:01Z",
83 payload: { type: "thread_settings_applied", thread_settings: { service_tier: serviceTier } },
84 })}\n`;
85}
86
7987function codexUsageLine(outputTokens: number, secondsOffset: number): string {
8088 return `${JSON.stringify({
8189 type: "event_msg",
......@@ -111,19 +119,24 @@ describe("readTranscriptRecords resume", () => {
111119
112120 it("carries the Codex reducer state across the resume boundary", async () => {
113121 const path = NodePath.join(dir, "rollout.jsonl");
114 await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex"));
122 await NodeFSP.writeFile(
123 path,
124 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"),
125 );
115126 const first = await readTranscriptRecords(path, "codex");
116127 assert.isNotNull(first);
117128 assert.strictEqual(first.records.length, 0);
118129
119 // The appended usage event has no turn_context or session_meta of its own;
120 // model and session must come from the state captured before the boundary.
130 // The appended usage event has no turn_context, thread settings, or
131 // session_meta of its own; model, tier, and session must come from the
132 // state captured before the boundary.
121133 await NodeFSP.appendFile(path, codexUsageLine(9, 5));
122134 const second = await readTranscriptRecords(path, "codex", first.position);
123135 assert.isNotNull(second);
124136 assert.isTrue(second.resumed);
125137 assert.strictEqual(second.records.length, 1);
126138 assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex");
139 assert.strictEqual(second.records[0]?.speed, "ultrafast");
127140 assert.strictEqual(second.records[0]?.sessionId, "codex-session-1");
128141 });
129142
apps/server/src/usage/usageTranscriptReader.ts+6-3
......@@ -108,6 +108,7 @@ const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = {
108108 id: true,
109109 session_id: true,
110110 model: true,
111 thread_settings: { service_tier: true },
111112 forked_from_id: true,
112113 source: { subagent: { thread_spawn: { parent_thread_id: true } } },
113114 info: { last_token_usage: true },
......@@ -245,9 +246,10 @@ async function guardMatches(
245246 * still match, so only appended lines are read; otherwise the whole file is
246247 * re-parsed from the start and `resumed` reports `false`.
247248 *
248 * Codex carries the active model on `turn_context` lines that hold no usage of
249 * their own, so those still have to pass through the reducer to keep model
250 * attribution correct.
249 * Codex carries the active model on `turn_context` lines and the service tier
250 * on `thread_settings_applied` lines. Neither holds usage of its own, but both
251 * still have to pass through the reducer to keep attribution and pricing
252 * correct.
251253 */
252254export async function readTranscriptRecords(
253255 filePath: string,
......@@ -283,6 +285,7 @@ export async function readTranscriptRecords(
283285 if (
284286 !mightCarryUsage(line, provider) &&
285287 !line.includes('"turn_context"') &&
288 !line.includes('"thread_settings_applied"') &&
286289 !line.includes('"session_meta"')
287290 ) {
288291 return;
apps/server/src/usage/usageTranscriptStreaming.test.ts+1-1
......@@ -122,7 +122,7 @@ describe("large usage records", () => {
122122 reasoningTokens: 0,
123123 },
124124 reportedCostUsd: 0.25,
125 fast: true,
125 speed: "fast",
126126 dedupeKey: "m1:r-m1",
127127 },
128128 ]);
apps/server/src/usage/usageTranscripts.test.ts+24-3
......@@ -53,15 +53,15 @@ describe("parseClaudeLine", () => {
5353 reasoningTokens: 0,
5454 });
5555 expect(record?.dedupeKey).toBe("msg_1:");
56 expect(record?.fast).toBe(false);
56 expect(record?.speed).toBe("standard");
5757 });
5858
5959 it("marks fast-mode requests", () => {
6060 const line = (speed: string) =>
6161 parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));
6262
63 expect(line("fast")?.fast).toBe(true);
64 expect(line("standard")?.fast).toBe(false);
63 expect(line("fast")?.speed).toBe("fast");
64 expect(line("standard")?.speed).toBe("standard");
6565 });
6666
6767 it("gives every content block of one message the same dedupe key", () => {
......@@ -148,6 +148,27 @@ describe("parseCodexLine", () => {
148148 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
149149 });
150150
151 it("carries the service tier from the latest thread settings", () => {
152 const settings = (thread_settings: Record<string, unknown>) =>
153 JSON.stringify({
154 type: "event_msg",
155 timestamp: "2026-08-01T05:17:42.000Z",
156 payload: { type: "thread_settings_applied", thread_settings },
157 });
158 const state = initialCodexScanState();
159 parseCodexLine(turnContext, state);
160 const speedAfter = (line: string, output: number) => {
161 parseCodexLine(line, state);
162 return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed;
163 };
164
165 expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard");
166 expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast");
167 expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast");
168 // Codex omits the field when no tier was requested.
169 expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard");
170 });
171
151172 // A forked/subagent rollout opens with the parent's history copied in and
152173 // every line re-stamped to the fork instant, then the ancestors' session
153174 // metas. Counting those again multiplied usage ~1.85x on real data (#5758).
apps/server/src/usage/usageTranscripts.ts+38-12
......@@ -8,6 +8,13 @@
88 */
99import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts";
1010
11/**
12 * Billing speed of a request. Faster speeds bill at a model-specific premium.
13 * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its
14 * own, more expensive tier.
15 */
16export type UsageSpeed = "standard" | "fast" | "ultrafast";
17
1118export interface UsageRecord {
1219 readonly provider: UsageProviderKind;
1320 readonly timestampMs: number;
......@@ -20,11 +27,8 @@ export interface UsageRecord {
2027 readonly sessionId: string;
2128 readonly totals: UsageTokenTotals;
2229 readonly reportedCostUsd: number | null;
23 /**
24 * Whether the request ran in fast mode, which bills at a model-specific
25 * multiple of the standard rate. Only Claude Code records this.
26 */
27 readonly fast: boolean;
30 /** Only Claude Code and Codex record a speed; other providers are `standard`. */
31 readonly speed: UsageSpeed;
2832 /**
2933 * Key for cross-file de-duplication, or `null` when the record is inherently
3034 * unique and needs no dedup.
......@@ -159,7 +163,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {
159163 reasoningTokens: 0,
160164 },
161165 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
162 fast: usageRecord["speed"] === "fast",
166 speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
163167 dedupeKey,
164168 };
165169}
......@@ -171,12 +175,14 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {
171175/**
172176 * Rolling state for a single Codex rollout file.
173177 *
174 * Codex `token_count` events carry no model, so the model is carried forward
175 * from the most recent `turn_context`. Sessions that switch models mid-run
176 * attribute correctly from the switch onward.
178 * Codex `token_count` events carry no model or service tier, so both are
179 * carried forward: the model from the most recent `turn_context`, the tier from
180 * the most recent `thread_settings_applied`. Sessions that switch either
181 * mid-run attribute correctly from the switch onward.
177182 */
178183export interface CodexScanState {
179184 model: string;
185 speed: UsageSpeed;
180186 sessionId: string;
181187 lastUsageSignature: string | null;
182188 sawSessionMeta: boolean;
......@@ -188,6 +194,7 @@ export interface CodexScanState {
188194export function initialCodexScanState(): CodexScanState {
189195 return {
190196 model: "",
197 speed: "standard",
191198 sessionId: "",
192199 lastUsageSignature: null,
193200 sawSessionMeta: false,
......@@ -265,6 +272,14 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR
265272 return null;
266273 }
267274
275 if (payloadType === "thread_settings_applied") {
276 const settings = payloadRecord["thread_settings"];
277 if (typeof settings === "object" && settings !== null) {
278 state.speed = codexSpeed((settings as Record<string, unknown>)["service_tier"]);
279 }
280 return null;
281 }
282
268283 if (payloadType !== "token_count") return null;
269284
270285 const info = payloadRecord["info"];
......@@ -323,13 +338,24 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR
323338 totals,
324339 // Codex does not report cost in the rollout.
325340 reportedCostUsd: null,
326 fast: false,
341 speed: state.speed,
327342 // Events surviving the fork-copy suppression above are unique to this
328343 // rollout, so they need no global dedup.
329344 dedupeKey: null,
330345 };
331346}
332347
348/**
349 * Maps a Codex `service_tier` to its billing speed. Codex omits the field when
350 * no tier was requested, which bills as standard, as do `default` and
351 * `standard`. `fast` is accepted as an alias of `priority`.
352 */
353function codexSpeed(serviceTier: unknown): UsageSpeed {
354 if (serviceTier === "priority" || serviceTier === "fast") return "fast";
355 if (serviceTier === "ultrafast") return "ultrafast";
356 return "standard";
357}
358
333359/* -------------------------------------------------------------------------- */
334360/* Grok Build */
335361/* -------------------------------------------------------------------------- */
......@@ -457,7 +483,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
457483 sessionId,
458484 totals: grokTotalsToUsage(topLevel),
459485 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
460 fast: false,
486 speed: "standard",
461487 // No prompt id means we cannot tell two same-second updates apart.
462488 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
463489 },
......@@ -504,7 +530,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
504530 sessionId,
505531 totals,
506532 reportedCostUsd,
507 fast: false,
533 speed: "standard",
508534 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
509535 });
510536 }