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";...@@ -34,6 +34,7 @@ import * as UsageService from "./UsageService.ts";
3434
35const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));35const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
36const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));36const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
37const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown));
3738
38function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {39function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
39 return `${JSON.stringify({40 return `${JSON.stringify({
...@@ -788,6 +789,98 @@ describe("UsageService", () => {...@@ -788,6 +789,98 @@ describe("UsageService", () => {
788 }).pipe(Effect.scoped),789 }).pipe(Effect.scoped),
789 );790 );
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
791 it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () =>884 it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () =>
792 Effect.gen(function* () {885 Effect.gen(function* () {
793 const { transcript, settings, home } = yield* setup;886 const { transcript, settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+15-5
...@@ -64,7 +64,9 @@ import {...@@ -64,7 +64,9 @@ import {
64 decodeScanCache,64 decodeScanCache,
65 dedupeWithinFile,65 dedupeWithinFile,
66 encodeScanCache,66 encodeScanCache,
67 LEGACY_SCAN_CACHE_FILE_NAME,
67 pruneScanCache,68 pruneScanCache,
69 SCAN_CACHE_FILE_NAME,
68 type ScanCache,70 type ScanCache,
69} from "./usageScanCache.ts";71} from "./usageScanCache.ts";
70import type { UsageRecord } from "./usageTranscripts.ts";72import type { UsageRecord } from "./usageTranscripts.ts";
...@@ -168,7 +170,8 @@ export const make = Effect.gen(function* () {...@@ -168,7 +170,8 @@ export const make = Effect.gen(function* () {
168 };170 };
169171
170 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");172 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);
172 let rates: RateTable = new Map();175 let rates: RateTable = new Map();
173 let ratesFetchedAtMs: number | null = null;176 let ratesFetchedAtMs: number | null = null;
174 let ratesStatus: UsagePricing["status"] = "unavailable";177 let ratesStatus: UsagePricing["status"] = "unavailable";
...@@ -362,10 +365,17 @@ export const make = Effect.gen(function* () {...@@ -362,10 +365,17 @@ export const make = Effect.gen(function* () {
362 */365 */
363 const ensureScanCacheLoaded = yield* Effect.cached(366 const ensureScanCacheLoaded = yield* Effect.cached(
364 Effect.gen(function* () {367 Effect.gen(function* () {
365 const document = yield* fileSystem.readFileString(scanCachePath).pipe(368 const readDocument = (filePath: string) =>
366 Effect.flatMap((raw) => decodeScanCacheFile(raw)),369 fileSystem.readFileString(filePath).pipe(
367 Effect.catchCause(() => Effect.succeed(null)),370 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
368 );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 }
369 if (document === null) return;379 if (document === null) return;
370 for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry);380 for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry);
371 const sources = decodeCachedSources(document);381 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...@@ -240,7 +240,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise<Us
240 "antigravity-unknown",240 "antigravity-unknown",
241 totals,241 totals,
242 reportedCostUsd: null,242 reportedCostUsd: null,
243 fast: false,243 speed: "standard",
244 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,244 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,
245 };245 };
246 records.push({246 records.push({
apps/server/src/usage/cursorUsageReader.ts+1-1
...@@ -253,7 +253,7 @@ export async function readCursorAccountUsage(...@@ -253,7 +253,7 @@ export async function readCursorAccountUsage(
253 sessionId,253 sessionId,
254 totals,254 totals,
255 reportedCostUsd,255 reportedCostUsd,
256 fast: false,256 speed: "standard",
257 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,257 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
258 });258 });
259 }259 }
apps/server/src/usage/opencodeUsageReader.ts+1-1
...@@ -64,7 +64,7 @@ function parseOpenCodeMessage(...@@ -64,7 +64,7 @@ function parseOpenCodeMessage(
64 // OpenCode writes zero for models without a known rate, including paid64 // OpenCode writes zero for models without a known rate, including paid
65 // subscription models. Let the shared price table estimate those records.65 // subscription models. Let the shared price table estimate those records.
66 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null,66 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null,
67 fast: false,67 speed: "standard",
68 dedupeKey: id ? `opencode:${id}` : null,68 dedupeKey: id ? `opencode:${id}` : null,
69 };69 };
70}70}
apps/server/src/usage/usageAggregation.test.ts+3-2
...@@ -12,7 +12,8 @@ const rates: RateTable = new Map([...@@ -12,7 +12,8 @@ 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 fast: null,
16 ultrafast: null,
16 },17 },
17 ],18 ],
18]);19]);
...@@ -32,7 +33,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {...@@ -32,7 +33,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
32 reasoningTokens: 0,33 reasoningTokens: 0,
33 },34 },
34 reportedCostUsd: null,35 reportedCostUsd: null,
35 fast: false,36 speed: "standard",
36 dedupeKey: null,37 dedupeKey: null,
37 ...overrides,38 ...overrides,
38 };39 };
apps/server/src/usage/usagePricing.test.ts+40-9
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { cursorRateModel } from "./cursorUsageReader.ts";3import { cursorRateModel } from "./cursorUsageReader.ts";
4import type { UsageSpeed } from "./usageTranscripts.ts";
4import {5import {
5 cacheSavingsUsd,6 cacheSavingsUsd,
6 createOverrideRateTable,7 createOverrideRateTable,
...@@ -23,11 +24,15 @@ describe("usage pricing", () => {...@@ -23,11 +24,15 @@ describe("usage pricing", () => {
23 outputTokens: 1_000_000,24 outputTokens: 1_000_000,
24 reasoningTokens: 500_000,25 reasoningTokens: 500_000,
25 };26 };
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 ) => ({
27 model,32 model,
28 totals,33 totals,
29 reportedCostUsd,34 reportedCostUsd,
30 fast,35 speed,
31 });36 });
3237
33 it("uses custom token rates ahead of public and provider-reported costs", () => {38 it("uses custom token rates ahead of public and provider-reported costs", () => {
...@@ -117,20 +122,46 @@ describe("usage pricing", () => {...@@ -117,20 +122,46 @@ describe("usage pricing", () => {
117 const overrides = createOverrideRateTable({122 const overrides = createOverrideRateTable({
118 "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 },123 "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 },
119 });124 });
120 const cost = (model: string, fast: boolean, custom?: typeof overrides) =>125 const cost = (model: string, speed: UsageSpeed, custom?: typeof overrides) =>
121 priceUsage(table, record(model, null, fast), custom).costUsd;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));128 expect(cost("claude-opus-5-5", "fast")).toBeCloseTo(2 * cost("claude-opus-5-5", "standard"));
124 expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo(129 expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, "fast"))).toBeCloseTo(
125 2 * cacheSavingsUsd(table, record("claude-opus-5-5")),130 2 * cacheSavingsUsd(table, record("claude-opus-5-5")),
126 );131 );
127 // No published fast tier, and custom prices, both stay at the standard rate.132 // 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));133 expect(cost("claude-fable-5-1", "fast")).toBe(cost("claude-fable-5-1", "standard"));
129 expect(cost("claude-opus-5-5", true, overrides)).toBe(134 expect(cost("claude-opus-5-5", "fast", overrides)).toBe(
130 cost("claude-opus-5-5", false, overrides),135 cost("claude-opus-5-5", "standard", overrides),
131 );136 );
132 });137 });
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
134 it("keeps the canonical Fable rate separate from DeepInfra in either order", () => {165 it("keeps the canonical Fable rate separate from DeepInfra in either order", () => {
135 const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const;166 const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const;
136 const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const;167 const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const;
apps/server/src/usage/usagePricing.ts+104-54
...@@ -9,33 +9,39 @@...@@ -9,33 +9,39 @@
9 */9 */
10import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";10import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts";
1111
12import type { UsageRecord } from "./usageTranscripts.ts";12import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
1313
14/**14/** Token rates for one billing speed. All values are USD per token. */
15 * The subset of a LiteLLM entry we price against. All values are USD per token.15export interface TokenRates {
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 {
23 readonly inputCostPerToken: number;16 readonly inputCostPerToken: number;
24 readonly outputCostPerToken: number;17 readonly outputCostPerToken: number;
25 readonly cacheReadCostPerToken: number;18 readonly cacheReadCostPerToken: number;
26 readonly cacheCreationCostPerToken: number;19 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 {
27 /**31 /**
28 * Multiple of the rates above billed for a fast-mode request, from LiteLLM's32 * From LiteLLM's `provider_specific_entry.fast` multiple (Claude fast mode),
29 * `provider_specific_entry.fast`. `1` when the model publishes no fast tier.33 * or else its `*_priority` rates (Codex `priority`).
30 */34 */
31 readonly fastMultiplier: number;35 readonly fast: TokenRates | null;
36 /** From LiteLLM's `*_ultrafast` rates (Codex `ultrafast`). */
37 readonly ultrafast: TokenRates | null;
32}38}
3339
34export type RateTable = ReadonlyMap<string, ModelRate>;40export type RateTable = ReadonlyMap<string, ModelRate>;
3541
36/**42/**
37 * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates43 * 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.
39 */45 */
40export function createOverrideRateTable(46export function createOverrideRateTable(
41 overrides: Readonly<Record<string, UsageModelPriceOverride>>,47 overrides: Readonly<Record<string, UsageModelPriceOverride>>,
...@@ -50,31 +56,68 @@ export function createOverrideRateTable(...@@ -50,31 +56,68 @@ export function createOverrideRateTable(
50 (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,56 (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
51 cacheCreationCostPerToken:57 cacheCreationCostPerToken:
52 (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,58 (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
53 fastMultiplier: 1,59 fast: null,
60 ultrafast: null,
54 },61 },
55 ]),62 ]),
56 );63 );
57}64}
5865
59/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */66/** One raw LiteLLM entry. Field names carry a tier suffix, e.g. `_priority`. */
60interface LiteLlmEntry {67type LiteLlmEntry = Readonly<Record<string, unknown>>;
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}
6768
68function finiteNumber(value: unknown): number | null {69function finiteNumber(value: unknown): number | null {
69 return typeof value === "number" && Number.isFinite(value) ? value : null;70 return typeof value === "number" && Number.isFinite(value) ? value : null;
70}71}
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
72/** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */115/** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */
73function fastMultiplier(entry: LiteLlmEntry): number {116function fastMultiplier(entry: LiteLlmEntry): number | null {
74 const specific = entry.provider_specific_entry;117 const specific = entry["provider_specific_entry"];
75 if (typeof specific !== "object" || specific === null) return 1;118 if (typeof specific !== "object" || specific === null) return null;
76 const fast = finiteNumber((specific as Record<string, unknown>)["fast"]);119 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;
78}121}
79122
80/**123/**
...@@ -94,21 +137,19 @@ export function parseRateTable(document: unknown): RateTable {...@@ -94,21 +137,19 @@ export function parseRateTable(document: unknown): RateTable {
94 for (const [name, raw] of Object.entries(document as Record<string, unknown>)) {137 for (const [name, raw] of Object.entries(document as Record<string, unknown>)) {
95 if (typeof raw !== "object" || raw === null) continue;138 if (typeof raw !== "object" || raw === null) continue;
96 const entry = raw as LiteLlmEntry;139 const entry = raw as LiteLlmEntry;
97 const input = finiteNumber(entry.input_cost_per_token);140 const standard = readTokenRates(entry, "");
98 const output = finiteNumber(entry.output_cost_per_token);141 if (standard === null) continue;
99 if (input === null || output === null) continue;
100142
101 const key = normalizeRateKey(name);143 const key = normalizeRateKey(name);
102 if (key.length === 0) continue;144 if (key.length === 0) continue;
145 const multiple = fastMultiplier(entry);
103 table.set(key, {146 table.set(key, {
104 inputCostPerToken: input,147 ...standard,
105 outputCostPerToken: output,148 fast:
106 // Anthropic bills cache reads at a discount and cache writes at a149 multiple === null
107 // premium. When a model omits them, cached input is priced as plain150 ? readTokenRates(entry, "_priority", standard)
108 // input rather than as free.151 : scaleTokenRates(standard, multiple),
109 cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,152 ultrafast: readTokenRates(entry, "_ultrafast", standard),
110 cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
111 fastMultiplier: fastMultiplier(entry),
112 });153 });
113 }154 }
114155
...@@ -131,16 +172,29 @@ export function parseRateTable(document: unknown): RateTable {...@@ -131,16 +172,29 @@ export function parseRateTable(document: unknown): RateTable {
131 return table;172 return table;
132}173}
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;
135 return (177 return (
136 a.inputCostPerToken === b.inputCostPerToken &&178 a.inputCostPerToken === b.inputCostPerToken &&
137 a.outputCostPerToken === b.outputCostPerToken &&179 a.outputCostPerToken === b.outputCostPerToken &&
138 a.cacheReadCostPerToken === b.cacheReadCostPerToken &&180 a.cacheReadCostPerToken === b.cacheReadCostPerToken &&
139 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken &&181 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken
140 a.fastMultiplier === b.fastMultiplier
141 );182 );
142}183}
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
144function normalizeRateKey(model: string): string {198function normalizeRateKey(model: string): string {
145 return model.trim().toLowerCase();199 return model.trim().toLowerCase();
146}200}
...@@ -186,7 +240,7 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null {...@@ -186,7 +240,7 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null {
186/** The parts of a transcript record that decide its price. */240/** The parts of a transcript record that decide its price. */
187export type PricedRecord = Pick<241export type PricedRecord = Pick<
188 UsageRecord,242 UsageRecord,
189 "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd"243 "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd"
190>;244>;
191245
192export interface PricedUsage {246export interface PricedUsage {
...@@ -214,14 +268,13 @@ export function priceUsage(...@@ -214,14 +268,13 @@ export function priceUsage(
214 const rate = override ?? lookupRate(table, record.rateModel ?? model);268 const rate = override ?? lookupRate(table, record.rateModel ?? model);
215 if (rate === null) return { costUsd: 0, costSource: "unpriced" };269 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
216270
217 const standardCostUsd =271 const rates = ratesAt(rate, record.speed);
218 totals.uncachedInputTokens * rate.inputCostPerToken +
219 totals.cachedInputTokens * rate.cacheReadCostPerToken +
220 totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
221 totals.outputTokens * rate.outputCostPerToken;
222
223 return {272 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,
225 costSource: "modelPriced",278 costSource: "modelPriced",
226 };279 };
227}280}
...@@ -238,9 +291,6 @@ export function cacheSavingsUsd(...@@ -238,9 +291,6 @@ export function cacheSavingsUsd(
238 const rate =291 const rate =
239 overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model);292 overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model);
240 if (rate === null) return 0;293 if (rate === null) return 0;
241 return (294 const rates = ratesAt(rate, record.speed);
242 record.totals.cachedInputTokens *295 return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken);
243 (rate.inputCostPerToken - rate.cacheReadCostPerToken) *
244 (record.fast ? rate.fastMultiplier : 1)
245 );
246}296}
apps/server/src/usage/usageScanCache.test.ts+10-7
...@@ -24,7 +24,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {...@@ -24,7 +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 speed: "standard",
28 dedupeKey: "msg_1:",28 dedupeKey: "msg_1:",
29 ...overrides,29 ...overrides,
30 };30 };
...@@ -61,7 +61,7 @@ describe("scan cache round trip", () => {...@@ -61,7 +61,7 @@ describe("scan cache round trip", () => {
61 [61 [
62 "/a.jsonl",62 "/a.jsonl",
63 100,63 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" })],
65 ],65 ],
66 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],66 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
67 ]);67 ]);
...@@ -79,11 +79,14 @@ describe("scan cache round trip", () => {...@@ -79,11 +79,14 @@ describe("scan cache round trip", () => {
79 size: 80,79 size: 80,
80 mtimeMs: 400,80 mtimeMs: 400,
81 provider: "codex",81 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 ],
83 tailRecords: [],85 tailRecords: [],
84 position: position({86 position: position({
85 codexState: {87 codexState: {
86 model: "gpt-5.2-codex",88 model: "gpt-6-astra",
89 speed: "ultrafast",
87 sessionId: "session-c",90 sessionId: "session-c",
88 lastUsageSignature: '{"input_tokens":1}',91 lastUsageSignature: '{"input_tokens":1}',
89 sawSessionMeta: true,92 sawSessionMeta: true,
...@@ -128,8 +131,8 @@ describe("scan cache round trip", () => {...@@ -128,8 +131,8 @@ describe("scan cache round trip", () => {
128 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);131 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);
129 });132 });
130133
131 it("drops an entry whose fast flag is not 0 or 1", () => {134 it("drops an entry whose speed is not a known index", () => {
132 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]]));135 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]]));
133 const row = encoded.files["/a.jsonl"]!.r[0]!;136 const row = encoded.files["/a.jsonl"]!.r[0]!;
134 const poisoned = {137 const poisoned = {
135 ...encoded,138 ...encoded,
...@@ -139,7 +142,7 @@ describe("scan cache round trip", () => {...@@ -139,7 +142,7 @@ describe("scan cache round trip", () => {
139 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);142 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);
140 });143 });
141144
142 it("rejects a document from the previous cache version", () => {145 it("rejects a document from before records carried a speed", () => {
143 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));146 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
144 const previous = { ...encoded, version: 3 };147 const previous = { ...encoded, version: 3 };
145148
apps/server/src/usage/usageScanCache.ts+46-16
...@@ -17,14 +17,33 @@...@@ -17,14 +17,33 @@
17import type { UsageProviderKind } from "@t3tools/contracts";17import type { UsageProviderKind } from "@t3tools/contracts";
1818
19import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";19import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
20import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts";20import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
2121
22// v2: Codex fork-copy suppression changed what a file parses to, so v122// v2: Codex fork-copy suppression changed what a file parses to, so v1
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.
26// v4: records carry Claude fast mode, which v3 rows never captured.26// 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
29export interface CachedFile {48export interface CachedFile {
30 readonly size: number;49 readonly size: number;
...@@ -59,7 +78,7 @@ type SerializedRecord = readonly [...@@ -59,7 +78,7 @@ type SerializedRecord = readonly [
59 reasoningTokens: number,78 reasoningTokens: number,
60 dedupeKey: string | null,79 dedupeKey: string | null,
61 reportedCostUsd: number | null,80 reportedCostUsd: number | null,
62 fast: 0 | 1,81 speed: number,
63];82];
6483
65interface SerializedFile {84interface SerializedFile {
...@@ -111,7 +130,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache {...@@ -111,7 +130,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache {
111 record.totals.reasoningTokens,130 record.totals.reasoningTokens,
112 record.dedupeKey,131 record.dedupeKey,
113 record.reportedCostUsd,132 record.reportedCostUsd,
114 record.fast ? 1 : 0,133 SPEEDS.indexOf(record.speed),
115 ];134 ];
116135
117 const files: Record<string, SerializedFile> = {};136 const files: Record<string, SerializedFile> = {};
...@@ -147,7 +166,14 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -147,7 +166,14 @@ export function decodeScanCache(document: unknown): ScanCache {
147 if (typeof document !== "object" || document === null) return cache;166 if (typeof document !== "object" || document === null) return cache;
148167
149 const root = document as Partial<SerializedCache>;168 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 }
151 if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache;177 if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache;
152 if (typeof root.files !== "object" || root.files === null) return cache;178 if (typeof root.files !== "object" || root.files === null) return cache;
153179
...@@ -180,8 +206,9 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -180,8 +206,9 @@ export function decodeScanCache(document: unknown): ScanCache {
180 reasoning,206 reasoning,
181 dedupeKey,207 dedupeKey,
182 reportedCostUsd,208 reportedCostUsd,
183 fast,209 speedIndex,
184 ] = row as SerializedRecord;210 ] = row as SerializedRecord;
211 const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined;
185212
186 const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;213 const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
187 if (214 if (
...@@ -193,7 +220,7 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -193,7 +220,7 @@ export function decodeScanCache(document: unknown): ScanCache {
193 !Number.isFinite(cacheCreation) ||220 !Number.isFinite(cacheCreation) ||
194 !Number.isFinite(output) ||221 !Number.isFinite(output) ||
195 !Number.isFinite(reasoning) ||222 !Number.isFinite(reasoning) ||
196 (fast !== 0 && fast !== 1)223 speed === undefined
197 ) {224 ) {
198 return null;225 return null;
199 }226 }
...@@ -211,7 +238,7 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -211,7 +238,7 @@ export function decodeScanCache(document: unknown): ScanCache {
211 reasoningTokens: reasoning,238 reasoningTokens: reasoning,
212 },239 },
213 reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,240 reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
214 fast: fast === 1,241 speed,
215 dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,242 dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
216 });243 });
217 }244 }
...@@ -242,7 +269,11 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -242,7 +269,11 @@ export function decodeScanCache(document: unknown): ScanCache {
242 ) {269 ) {
243 continue;270 continue;
244 }271 }
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);
246 if (codexState === undefined) continue;277 if (codexState === undefined) continue;
247278
248 const provider: UsageProviderKind = entry.p;279 const provider: UsageProviderKind = entry.p;
...@@ -251,17 +282,14 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -251,17 +282,14 @@ export function decodeScanCache(document: unknown): ScanCache {
251 if (records === null || tailRecords === null) continue;282 if (records === null || tailRecords === null) continue;
252283
253 cache.set(path, {284 cache.set(path, {
254 size: entry.s,285 size: legacyCodex ? -1 : entry.s,
255 mtimeMs: entry.m,286 mtimeMs: entry.m,
256 provider,287 provider,
257 records,288 records,
258 tailRecords,289 tailRecords,
259 position: {290 position: legacyCodex
260 resumeOffset: entry.o,291 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null }
261 guardLength: entry.gl,292 : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState },
262 guardHash: entry.gh,
263 codexState,
264 },
265 });293 });
266 }294 }
267295
...@@ -279,6 +307,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {...@@ -279,6 +307,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {
279 const state = value as Partial<CodexScanState>;307 const state = value as Partial<CodexScanState>;
280 if (308 if (
281 typeof state.model !== "string" ||309 typeof state.model !== "string" ||
310 !isSpeed(state.speed) ||
282 typeof state.sessionId !== "string" ||311 typeof state.sessionId !== "string" ||
283 (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||312 (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||
284 typeof state.sawSessionMeta !== "boolean" ||313 typeof state.sawSessionMeta !== "boolean" ||
...@@ -290,6 +319,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {...@@ -290,6 +319,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined {
290 }319 }
291 return {320 return {
292 model: state.model,321 model: state.model,
322 speed: state.speed,
293 sessionId: state.sessionId,323 sessionId: state.sessionId,
294 lastUsageSignature: state.lastUsageSignature ?? null,324 lastUsageSignature: state.lastUsageSignature ?? null,
295 sawSessionMeta: state.sawSessionMeta,325 sawSessionMeta: state.sawSessionMeta,
apps/server/src/usage/usageTranscriptReader.test.ts+16-3
...@@ -76,6 +76,14 @@ function codexModelLine(model: string): string {...@@ -76,6 +76,14 @@ function codexModelLine(model: string): string {
76 })}\n`;76 })}\n`;
77}77}
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
79function codexUsageLine(outputTokens: number, secondsOffset: number): string {87function codexUsageLine(outputTokens: number, secondsOffset: number): string {
80 return `${JSON.stringify({88 return `${JSON.stringify({
81 type: "event_msg",89 type: "event_msg",
...@@ -111,19 +119,24 @@ describe("readTranscriptRecords resume", () => {...@@ -111,19 +119,24 @@ describe("readTranscriptRecords resume", () => {
111119
112 it("carries the Codex reducer state across the resume boundary", async () => {120 it("carries the Codex reducer state across the resume boundary", async () => {
113 const path = NodePath.join(dir, "rollout.jsonl");121 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 );
115 const first = await readTranscriptRecords(path, "codex");126 const first = await readTranscriptRecords(path, "codex");
116 assert.isNotNull(first);127 assert.isNotNull(first);
117 assert.strictEqual(first.records.length, 0);128 assert.strictEqual(first.records.length, 0);
118129
119 // The appended usage event has no turn_context or session_meta of its own;130 // The appended usage event has no turn_context, thread settings, or
120 // model and session must come from the state captured before the boundary.131 // session_meta of its own; model, tier, and session must come from the
132 // state captured before the boundary.
121 await NodeFSP.appendFile(path, codexUsageLine(9, 5));133 await NodeFSP.appendFile(path, codexUsageLine(9, 5));
122 const second = await readTranscriptRecords(path, "codex", first.position);134 const second = await readTranscriptRecords(path, "codex", first.position);
123 assert.isNotNull(second);135 assert.isNotNull(second);
124 assert.isTrue(second.resumed);136 assert.isTrue(second.resumed);
125 assert.strictEqual(second.records.length, 1);137 assert.strictEqual(second.records.length, 1);
126 assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex");138 assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex");
139 assert.strictEqual(second.records[0]?.speed, "ultrafast");
127 assert.strictEqual(second.records[0]?.sessionId, "codex-session-1");140 assert.strictEqual(second.records[0]?.sessionId, "codex-session-1");
128 });141 });
129142
apps/server/src/usage/usageTranscriptReader.ts+6-3
...@@ -108,6 +108,7 @@ const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = {...@@ -108,6 +108,7 @@ const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = {
108 id: true,108 id: true,
109 session_id: true,109 session_id: true,
110 model: true,110 model: true,
111 thread_settings: { service_tier: true },
111 forked_from_id: true,112 forked_from_id: true,
112 source: { subagent: { thread_spawn: { parent_thread_id: true } } },113 source: { subagent: { thread_spawn: { parent_thread_id: true } } },
113 info: { last_token_usage: true },114 info: { last_token_usage: true },
...@@ -245,9 +246,10 @@ async function guardMatches(...@@ -245,9 +246,10 @@ async function guardMatches(
245 * still match, so only appended lines are read; otherwise the whole file is246 * still match, so only appended lines are read; otherwise the whole file is
246 * re-parsed from the start and `resumed` reports `false`.247 * re-parsed from the start and `resumed` reports `false`.
247 *248 *
248 * Codex carries the active model on `turn_context` lines that hold no usage of249 * Codex carries the active model on `turn_context` lines and the service tier
249 * their own, so those still have to pass through the reducer to keep model250 * on `thread_settings_applied` lines. Neither holds usage of its own, but both
250 * attribution correct.251 * still have to pass through the reducer to keep attribution and pricing
252 * correct.
251 */253 */
252export async function readTranscriptRecords(254export async function readTranscriptRecords(
253 filePath: string,255 filePath: string,
...@@ -283,6 +285,7 @@ export async function readTranscriptRecords(...@@ -283,6 +285,7 @@ export async function readTranscriptRecords(
283 if (285 if (
284 !mightCarryUsage(line, provider) &&286 !mightCarryUsage(line, provider) &&
285 !line.includes('"turn_context"') &&287 !line.includes('"turn_context"') &&
288 !line.includes('"thread_settings_applied"') &&
286 !line.includes('"session_meta"')289 !line.includes('"session_meta"')
287 ) {290 ) {
288 return;291 return;
apps/server/src/usage/usageTranscriptStreaming.test.ts+1-1
...@@ -122,7 +122,7 @@ describe("large usage records", () => {...@@ -122,7 +122,7 @@ describe("large usage records", () => {
122 reasoningTokens: 0,122 reasoningTokens: 0,
123 },123 },
124 reportedCostUsd: 0.25,124 reportedCostUsd: 0.25,
125 fast: true,125 speed: "fast",
126 dedupeKey: "m1:r-m1",126 dedupeKey: "m1:r-m1",
127 },127 },
128 ]);128 ]);
apps/server/src/usage/usageTranscripts.test.ts+24-3
...@@ -53,15 +53,15 @@ describe("parseClaudeLine", () => {...@@ -53,15 +53,15 @@ describe("parseClaudeLine", () => {
53 reasoningTokens: 0,53 reasoningTokens: 0,
54 });54 });
55 expect(record?.dedupeKey).toBe("msg_1:");55 expect(record?.dedupeKey).toBe("msg_1:");
56 expect(record?.fast).toBe(false);56 expect(record?.speed).toBe("standard");
57 });57 });
5858
59 it("marks fast-mode requests", () => {59 it("marks fast-mode requests", () => {
60 const line = (speed: string) =>60 const line = (speed: string) =>
61 parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));61 parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));
6262
63 expect(line("fast")?.fast).toBe(true);63 expect(line("fast")?.speed).toBe("fast");
64 expect(line("standard")?.fast).toBe(false);64 expect(line("standard")?.speed).toBe("standard");
65 });65 });
6666
67 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", () => {
...@@ -148,6 +148,27 @@ describe("parseCodexLine", () => {...@@ -148,6 +148,27 @@ describe("parseCodexLine", () => {
148 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();148 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
149 });149 });
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
151 // A forked/subagent rollout opens with the parent's history copied in and172 // A forked/subagent rollout opens with the parent's history copied in and
152 // every line re-stamped to the fork instant, then the ancestors' session173 // every line re-stamped to the fork instant, then the ancestors' session
153 // metas. Counting those again multiplied usage ~1.85x on real data (#5758).174 // metas. Counting those again multiplied usage ~1.85x on real data (#5758).
apps/server/src/usage/usageTranscripts.ts+38-12
...@@ -8,6 +8,13 @@...@@ -8,6 +8,13 @@
8 */8 */
9import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts";9import 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
11export interface UsageRecord {18export interface UsageRecord {
12 readonly provider: UsageProviderKind;19 readonly provider: UsageProviderKind;
13 readonly timestampMs: number;20 readonly timestampMs: number;
...@@ -20,11 +27,8 @@ export interface UsageRecord {...@@ -20,11 +27,8 @@ export interface UsageRecord {
20 readonly sessionId: string;27 readonly sessionId: string;
21 readonly totals: UsageTokenTotals;28 readonly totals: UsageTokenTotals;
22 readonly reportedCostUsd: number | null;29 readonly reportedCostUsd: number | null;
23 /**30 /** Only Claude Code and Codex record a speed; other providers are `standard`. */
24 * Whether the request ran in fast mode, which bills at a model-specific31 readonly speed: UsageSpeed;
25 * multiple of the standard rate. Only Claude Code records this.
26 */
27 readonly fast: boolean;
28 /**32 /**
29 * Key for cross-file de-duplication, or `null` when the record is inherently33 * Key for cross-file de-duplication, or `null` when the record is inherently
30 * unique and needs no dedup.34 * unique and needs no dedup.
...@@ -159,7 +163,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {...@@ -159,7 +163,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {
159 reasoningTokens: 0,163 reasoningTokens: 0,
160 },164 },
161 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,165 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
162 fast: usageRecord["speed"] === "fast",166 speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
163 dedupeKey,167 dedupeKey,
164 };168 };
165}169}
...@@ -171,12 +175,14 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {...@@ -171,12 +175,14 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null {
171/**175/**
172 * Rolling state for a single Codex rollout file.176 * Rolling state for a single Codex rollout file.
173 *177 *
174 * Codex `token_count` events carry no model, so the model is carried forward178 * Codex `token_count` events carry no model or service tier, so both are
175 * from the most recent `turn_context`. Sessions that switch models mid-run179 * carried forward: the model from the most recent `turn_context`, the tier from
176 * attribute correctly from the switch onward.180 * the most recent `thread_settings_applied`. Sessions that switch either
181 * mid-run attribute correctly from the switch onward.
177 */182 */
178export interface CodexScanState {183export interface CodexScanState {
179 model: string;184 model: string;
185 speed: UsageSpeed;
180 sessionId: string;186 sessionId: string;
181 lastUsageSignature: string | null;187 lastUsageSignature: string | null;
182 sawSessionMeta: boolean;188 sawSessionMeta: boolean;
...@@ -188,6 +194,7 @@ export interface CodexScanState {...@@ -188,6 +194,7 @@ export interface CodexScanState {
188export function initialCodexScanState(): CodexScanState {194export function initialCodexScanState(): CodexScanState {
189 return {195 return {
190 model: "",196 model: "",
197 speed: "standard",
191 sessionId: "",198 sessionId: "",
192 lastUsageSignature: null,199 lastUsageSignature: null,
193 sawSessionMeta: false,200 sawSessionMeta: false,
...@@ -265,6 +272,14 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR...@@ -265,6 +272,14 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR
265 return null;272 return null;
266 }273 }
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
268 if (payloadType !== "token_count") return null;283 if (payloadType !== "token_count") return null;
269284
270 const info = payloadRecord["info"];285 const info = payloadRecord["info"];
...@@ -323,13 +338,24 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR...@@ -323,13 +338,24 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR
323 totals,338 totals,
324 // Codex does not report cost in the rollout.339 // Codex does not report cost in the rollout.
325 reportedCostUsd: null,340 reportedCostUsd: null,
326 fast: false,341 speed: state.speed,
327 // Events surviving the fork-copy suppression above are unique to this342 // Events surviving the fork-copy suppression above are unique to this
328 // rollout, so they need no global dedup.343 // rollout, so they need no global dedup.
329 dedupeKey: null,344 dedupeKey: null,
330 };345 };
331}346}
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
333/* -------------------------------------------------------------------------- */359/* -------------------------------------------------------------------------- */
334/* Grok Build */360/* Grok Build */
335/* -------------------------------------------------------------------------- */361/* -------------------------------------------------------------------------- */
...@@ -457,7 +483,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {...@@ -457,7 +483,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
457 sessionId,483 sessionId,
458 totals: grokTotalsToUsage(topLevel),484 totals: grokTotalsToUsage(topLevel),
459 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),485 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
460 fast: false,486 speed: "standard",
461 // No prompt id means we cannot tell two same-second updates apart.487 // No prompt id means we cannot tell two same-second updates apart.
462 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,488 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
463 },489 },
...@@ -504,7 +530,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {...@@ -504,7 +530,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
504 sessionId,530 sessionId,
505 totals,531 totals,
506 reportedCostUsd,532 reportedCostUsd,
507 fast: false,533 speed: "standard",
508 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,534 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
509 });535 });
510 }536 }