From 94682ac145c05a47689ba2ccac52bc146b2d3392 Mon Sep 17 00:00:00 2001 From: lbgos <285507185+lbgos@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:52:31 +0300 Subject: [PATCH 1/3] fix(server): usage prices Claude 1-hour cache writes at their own rate --- apps/server/src/usage/UsageService.test.ts | 82 ++++++++++++++++++- apps/server/src/usage/UsageService.ts | 11 ++- apps/server/src/usage/usagePricing.test.ts | 21 +++++ apps/server/src/usage/usagePricing.ts | 82 ++++++++++++------- apps/server/src/usage/usageScanCache.test.ts | 10 ++- apps/server/src/usage/usageScanCache.ts | 33 +++++--- .../server/src/usage/usageTranscripts.test.ts | 20 +++++ apps/server/src/usage/usageTranscripts.ts | 11 +++ 8 files changed, 226 insertions(+), 44 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 037eb5cc8ef48..76816033995cd 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -863,7 +863,7 @@ describe("UsageService", () => { yield* Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; - const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + const cachePath = NodePath.join(stateDir, "usage-scan-cache-v6.json"); const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); yield* (yield* UsageService.make).readSummary(WINDOW); @@ -917,6 +917,86 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "upgrades a v5 cache: reprices live Claude 1-hour cache writes, keeps deleted transcripts", + () => + Effect.gen(function* () { + const { home, transcript, settings } = yield* setup; + const cacheWriteLine = (id: number) => + `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-opus-5-5", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 10, + cache_creation: { ephemeral_1h_input_tokens: 10, ephemeral_5m_input_tokens: 0 }, + }, + }, + })}\n`; + const deleted = NodePath.join(NodePath.dirname(transcript), "deleted.jsonl"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, cacheWriteLine(1)); + await NodeFSP.writeFile(deleted, cacheWriteLine(2)); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, "usage-scan-cache-v6.json"); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + yield* (yield* UsageService.make).readSummary(WINDOW); + + // Rewrite the cache as a v5 server left it: rows without 1-hour writes. + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + for (const file of Object.values(document.files)) { + file.r = file.r.map((row) => row.slice(0, 11)); + } + const text = encodeUnknownJsonString({ ...document, version: 5 }); + await NodeFSP.writeFile(legacyPath, text); + await NodeFSP.rm(cachePath); + await NodeFSP.rm(deleted); + return text; + }); + + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + // The live transcript re-parses at the 1-hour rate (10 x 2); the + // deleted one keeps its saved v5 usage at the 5-minute rate (10 x 1). + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 30, + ); + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-v5-upgrade-test", + home, + settings, + ratesDocument: { + "claude-opus-5-5": { + input_cost_per_token: 0, + output_cost_per_token: 0, + cache_creation_input_token_cost: 1, + cache_creation_input_token_cost_above_1hr: 2, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c2206a53d33cf..0306487e6cc2c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -64,7 +64,7 @@ import { import { decodeScanCache, dedupeWithinFile, - LEGACY_SCAN_CACHE_FILE_NAME, + LEGACY_SCAN_CACHE_FILE_NAMES, makeScanCacheWriter, pruneScanCache, SCAN_CACHE_FILE_NAME, @@ -204,7 +204,9 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); - const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); + const legacyScanCachePaths = LEGACY_SCAN_CACHE_FILE_NAMES.map((name) => + path.join(config.stateDir, name), + ); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -409,8 +411,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); let document = yield* readDocument(scanCachePath); - if (document === null) { - document = yield* readDocument(legacyScanCachePath); + for (const legacyPath of legacyScanCachePaths) { + if (document !== null) break; + document = yield* readDocument(legacyPath); // Write the migrated cache to its own file on the next scan. cacheDirty = document !== null; } diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 0a324dd734b44..8e7eff43c0af3 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -174,6 +174,27 @@ describe("usage pricing", () => { }); }); + it("prices 1-hour cache writes at their own rate", () => { + const table = parseRateTable({ + "claude-opus-5-5": { + ...rate(4e-6, 4e-7), + cache_creation_input_token_cost: 5e-6, + cache_creation_input_token_cost_above_1hr: 8e-6, + provider_specific_entry: { fast: 2 }, + }, + // No published 1-hour rate: those writes bill at the 5-minute rate. + "claude-sonnet-5-5": { ...rate(2e-6, 2e-7), cache_creation_input_token_cost: 2.5e-6 }, + }); + const cacheWrite = (model: string, cacheCreation1hTokens: number, speed: UsageSpeed) => + priceUsage(table, { ...record(model, null, speed), cacheCreation1hTokens }).categoryCostUsd + ?.cacheWrite; + + // 250k tokens at 5 per million plus 750k at 8. + expect(cacheWrite("claude-opus-5-5", 750_000, "standard")).toBeCloseTo(7.25); + expect(cacheWrite("claude-opus-5-5", 750_000, "fast")).toBeCloseTo(14.5); + expect(cacheWrite("claude-sonnet-5-5", 750_000, "standard")).toBeCloseTo(2.5); + }); + it("prices Codex priority and ultrafast requests at their published tier rates", () => { const table = parseRateTable({ "gpt-6-astra": { diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 0ec7ef8d622f8..76a5c6e40394f 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -11,7 +11,6 @@ import type { UsageCategoryCost, UsageCostSource, UsageModelPriceOverride, - UsageTokenTotals, } from "@t3tools/contracts"; import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; @@ -22,6 +21,8 @@ export interface TokenRates { readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; + /** Cache writes with a 1-hour TTL. Equals the 5-minute rate when unpublished. */ + readonly cacheCreation1hCostPerToken: number; } /** @@ -52,19 +53,23 @@ export function createOverrideRateTable( overrides: Readonly>, ): RateTable { return new Map( - Object.entries(overrides).map(([model, prices]) => [ - model.trim(), - { - inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000, - outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000, - cacheReadCostPerToken: - (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, - cacheCreationCostPerToken: - (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, - fast: null, - ultrafast: null, - }, - ]), + Object.entries(overrides).map(([model, prices]) => { + const cacheCreationCostPerToken = + (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000; + return [ + model.trim(), + { + inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000, + outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000, + cacheReadCostPerToken: + (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + cacheCreationCostPerToken, + cacheCreation1hCostPerToken: cacheCreationCostPerToken, + fast: null, + ultrafast: null, + }, + ]; + }), ); } @@ -82,7 +87,8 @@ function finiteNumber(value: unknown): number | null { * Anthropic bills cache reads at a discount and cache writes at a premium. * When the standard tier omits them, cached input is priced as plain input * rather than as free. A faster tier that omits them keeps the standard tier's - * cache-to-input ratio. + * cache-to-input ratio. A missing 1-hour write rate falls back to the 5-minute + * one. */ function readTokenRates( entry: LiteLlmEntry, @@ -92,18 +98,29 @@ function readTokenRates( const input = finiteNumber(entry[`input_cost_per_token${suffix}`]); const output = finiteNumber(entry[`output_cost_per_token${suffix}`]); if (input === null || output === null) return null; - const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") => + const cacheRate = ( + name: string, + field: "cacheReadCostPerToken" | "cacheCreationCostPerToken" | "cacheCreation1hCostPerToken", + fallback: number, + ) => finiteNumber(entry[`${name}${suffix}`]) ?? (standard !== undefined && standard.inputCostPerToken > 0 ? (standard[field] / standard.inputCostPerToken) * input - : input); + : fallback); + const cacheCreationCostPerToken = cacheRate( + "cache_creation_input_token_cost", + "cacheCreationCostPerToken", + input, + ); return { inputCostPerToken: input, outputCostPerToken: output, - cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"), - cacheCreationCostPerToken: cacheRate( - "cache_creation_input_token_cost", - "cacheCreationCostPerToken", + cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken", input), + cacheCreationCostPerToken, + cacheCreation1hCostPerToken: cacheRate( + "cache_creation_input_token_cost_above_1hr", + "cacheCreation1hCostPerToken", + cacheCreationCostPerToken, ), }; } @@ -114,6 +131,7 @@ function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates { outputCostPerToken: rates.outputCostPerToken * multiple, cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple, cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple, + cacheCreation1hCostPerToken: rates.cacheCreation1hCostPerToken * multiple, }; } @@ -183,7 +201,8 @@ function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && + a.cacheCreation1hCostPerToken === b.cacheCreation1hCostPerToken ); } @@ -265,7 +284,7 @@ function resolveRate(table: RateTable, model: string): ModelRate | null { /** The parts of a transcript record that decide its price. */ export type PricedRecord = Pick< UsageRecord, - "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd" + "model" | "rateModel" | "totals" | "cacheCreation1hTokens" | "speed" | "reportedCostUsd" >; export interface PricedUsage { @@ -277,11 +296,18 @@ export interface PricedUsage { readonly speedPremiumUsd: number; } -function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost { +function costByCategory(record: PricedRecord, rates: TokenRates): UsageCategoryCost { + const { totals } = record; + const cacheCreation1hTokens = Math.min( + record.cacheCreation1hTokens ?? 0, + totals.cacheCreationTokens, + ); return { input: totals.uncachedInputTokens * rates.inputCostPerToken, cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken, - cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken, + cacheWrite: + (totals.cacheCreationTokens - cacheCreation1hTokens) * rates.cacheCreationCostPerToken + + cacheCreation1hTokens * rates.cacheCreation1hCostPerToken, output: totals.outputTokens * rates.outputCostPerToken, }; } @@ -304,7 +330,7 @@ export function priceUsage( record: PricedRecord, overrides?: RateTable, ): PricedUsage { - const { model, totals, reportedCostUsd } = record; + const { model, reportedCostUsd } = record; const override = overrides?.get(model.trim()); const reported = override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd) @@ -321,11 +347,11 @@ export function priceUsage( return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported"); } - const listCost = costByCategory(totals, ratesAt(rate, record.speed)); + const listCost = costByCategory(record, ratesAt(rate, record.speed)); const listCostUsd = sumCategories(listCost); if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported"); const premiumUsd = - record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate)); + record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(record, rate)); const scale = reported === null ? 1 : reported / listCostUsd; return { costUsd: reported ?? listCostUsd, diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index b59768cf892bf..6f644146ae58a 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -62,7 +62,15 @@ describe("scan cache round trip", () => { [ "/a.jsonl", 100, - [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })], + [ + record(), + record({ + dedupeKey: "msg_2:", + model: "claude-opus-5-5", + speed: "fast", + cacheCreation1hTokens: 8, + }), + ], ], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 04e7ab4ce9829..1a004d2ec8068 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,17 +26,23 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts // v4: records carry Claude fast mode, which v3 rows never captured. // v5: Codex records carry their service tier. v4 rows store speed the same // way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries. -const USAGE_SCAN_CACHE_VERSION = 5 as const; +// v6: Claude records carry their 1-hour cache writes. Older rows load with +// none; see `decodeScanCache` for older Claude entries. +const USAGE_SCAN_CACHE_VERSION = 6 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** * Each cache version writes its own file in the state directory. An older * server sharing that directory cannot read a newer cache and would replace * it, dropping saved usage for deleted transcripts. Separate files keep both. - * A v5 server reads the legacy (v4) file once, when its own file is missing. + * When its own file is missing, a server reads the newest legacy file once. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json"; -export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v6.json"; +/** Older cache files, newest first. */ +export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v5.json", + "usage-scan-cache.json", +] as const; /** Serialised as the index into this list. */ const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; @@ -79,6 +85,7 @@ type SerializedRecord = readonly [ dedupeKey: string | null, reportedCostUsd: number | null, speed: number, + cacheCreation1hTokens?: number, ]; interface SerializedFile { @@ -137,6 +144,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile record.dedupeKey, record.reportedCostUsd, SPEEDS.indexOf(record.speed), + record.cacheCreation1hTokens ?? 0, ]; return { s: entry.size, @@ -258,6 +266,7 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey, reportedCostUsd, speedIndex, + cacheCreation1h = 0, ] = row as SerializedRecord; const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; @@ -271,6 +280,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(cacheCreation) || !Number.isFinite(output) || !Number.isFinite(reasoning) || + !Number.isFinite(cacheCreation1h) || speed === undefined ) { return null; @@ -288,6 +298,7 @@ export function decodeScanCache(document: unknown): ScanCache { outputTokens: output, reasoningTokens: reasoning, }, + ...(cacheCreation1h > 0 ? { cacheCreation1hTokens: cacheCreation1h } : {}), reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, speed, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, @@ -321,10 +332,12 @@ export function decodeScanCache(document: unknown): ScanCache { continue; } // v4 Codex records predate service tiers, so they all priced as standard. - // Keep them, because the rollout may be gone, but make a live rollout - // re-parse whole: no file has size -1, and a zero position cannot resume. - const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; - const codexState = legacyCodex ? null : decodeCodexState(entry.cs); + // Pre-v6 Claude records predate 1-hour cache writes, so those priced at the + // 5-minute rate. Keep them, because the transcript may be gone, but make a + // live one re-parse whole: no file has size -1, and a zero position cannot + // resume. + const reparse = (entry.p === "codex" && version < 5) || (entry.p === "claude" && version < 6); + const codexState = reparse ? null : decodeCodexState(entry.cs); if (codexState === undefined) continue; const provider: UsageProviderKind = entry.p; @@ -333,12 +346,12 @@ export function decodeScanCache(document: unknown): ScanCache { if (records === null || tailRecords === null) continue; cache.set(path, { - size: legacyCodex ? -1 : entry.s, + size: reparse ? -1 : entry.s, mtimeMs: entry.m, provider, records, tailRecords, - position: legacyCodex + position: reparse ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, }); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8d668a1398223..e892f2f72ac31 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -16,6 +16,7 @@ function claudeLine(overrides: { model?: string; outputTokens?: number; speed?: string; + cacheCreation1hTokens?: number; }): string { return JSON.stringify({ type: "assistant", @@ -33,6 +34,14 @@ function claudeLine(overrides: { cache_read_input_tokens: 1000, output_tokens: overrides.outputTokens ?? 286, ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), + ...(overrides.cacheCreation1hTokens === undefined + ? {} + : { + cache_creation: { + ephemeral_1h_input_tokens: overrides.cacheCreation1hTokens, + ephemeral_5m_input_tokens: 66818 - overrides.cacheCreation1hTokens, + }, + }), }, }, }); @@ -56,6 +65,17 @@ describe("parseClaudeLine", () => { expect(record?.speed).toBe("standard"); }); + it("splits out cache writes with a 1-hour TTL", () => { + const line = (cacheCreation1hTokens?: number) => + parseClaudeLine( + claudeLine({ messageId: "msg_1", contentType: "text", cacheCreation1hTokens }), + ); + + expect(line(60000)?.cacheCreation1hTokens).toBe(60000); + expect(line(60000)?.totals.cacheCreationTokens).toBe(66818); + expect(line()?.cacheCreation1hTokens).toBeUndefined(); + }); + it("marks fast-mode requests", () => { const line = (speed: string) => parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 451f8a726b3ec..090ce62e4b3dc 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -26,6 +26,11 @@ export interface UsageRecord { readonly rateModel?: string; readonly sessionId: string; readonly totals: UsageTokenTotals; + /** + * The part of `totals.cacheCreationTokens` written with a 1-hour TTL, which + * bills above the 5-minute rate. Only Claude Code records the split. + */ + readonly cacheCreation1hTokens?: number; readonly reportedCostUsd: number | null; /** Only Claude Code and Codex record a speed; other providers are `standard`. */ readonly speed: UsageSpeed; @@ -138,6 +143,11 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; const cost = record["costUSD"]; + const cacheCreation = usageRecord["cache_creation"]; + const cacheCreation1hTokens = + typeof cacheCreation === "object" && cacheCreation !== null + ? int((cacheCreation as Record)["ephemeral_1h_input_tokens"]) + : 0; return { provider: "claude", @@ -152,6 +162,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { // Anthropic folds thinking tokens into output and does not break them out. reasoningTokens: 0, }, + ...(cacheCreation1hTokens > 0 ? { cacheCreation1hTokens } : {}), reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, speed: usageRecord["speed"] === "fast" ? "fast" : "standard", dedupeKey, From 0d6a856a6a17b04cb164d78d0d9c521a7918fe34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:00:35 +0000 Subject: [PATCH 2/3] test(server): tighten Claude cache write pricing coverage --- .../src/provider/Drivers/claudeUsage.test.ts | 19 --- apps/server/src/usage/UsageService.test.ts | 157 ++++++++++-------- .../server/src/usage/usageAggregation.test.ts | 2 + apps/server/src/usage/usagePricing.test.ts | 62 ++++--- apps/server/src/usage/usageScanCache.test.ts | 3 +- .../usage/usageTranscriptStreaming.test.ts | 2 + 6 files changed, 120 insertions(+), 125 deletions(-) diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index e221f6eeb1a19..c65cb237d39ad 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -8,7 +8,6 @@ function claudeLine(overrides: { model?: string; outputTokens?: number; speed?: string; - cacheCreation1hTokens?: number; }): string { return JSON.stringify({ type: "assistant", @@ -25,14 +24,6 @@ function claudeLine(overrides: { cache_creation_input_tokens: 66818, cache_read_input_tokens: 1000, output_tokens: overrides.outputTokens ?? 286, - ...(overrides.cacheCreation1hTokens === undefined - ? {} - : { - cache_creation: { - ephemeral_1h_input_tokens: overrides.cacheCreation1hTokens, - ephemeral_5m_input_tokens: 66818 - overrides.cacheCreation1hTokens, - }, - }), ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), }, }, @@ -57,16 +48,6 @@ describe("parseClaudeLine", () => { expect(record?.speed).toBe("standard"); }); - it("splits out cache writes with a 1-hour TTL", () => { - const line = (cacheCreation1hTokens?: number) => - parseClaudeLine( - claudeLine({ messageId: "msg_1", contentType: "text", cacheCreation1hTokens }), - ); - expect(line(60000)?.cacheCreation1hTokens).toBe(60000); - expect(line(60000)?.totals.cacheCreationTokens).toBe(66818); - expect(line()?.cacheCreation1hTokens).toBeUndefined(); - }); - it("marks fast-mode requests", () => { const line = (speed: string) => parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 297dcdb72fbb9..3468b656871cd 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1591,84 +1591,95 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( - "upgrades a v5 cache: reprices live Claude 1-hour cache writes, keeps deleted transcripts", - () => - Effect.gen(function* () { - const { home, transcript, settings } = yield* setup; - const cacheWriteLine = (id: number) => - `${JSON.stringify({ - type: "assistant", - timestamp: "2026-08-01T10:00:00Z", - requestId: `req_${id}`, - sessionId: "session-1", - message: { - id: `msg_${id}`, - model: "claude-opus-5-5", - usage: { - input_tokens: 0, - output_tokens: 0, - cache_creation_input_tokens: 10, - cache_creation: { ephemeral_1h_input_tokens: 10, ephemeral_5m_input_tokens: 0 }, + it.live("migrates v5 Claude costs and retains deleted history through a v6 restart", () => + Effect.gen(function* () { + const { home, transcript, settings } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 0, "claude-opus-5-5").replace( + '"input_tokens":10', + '"input_tokens":0,"cache_creation_input_tokens":10,"cache_creation":{"ephemeral_1h_input_tokens":6,"ephemeral_5m_input_tokens":4}', + ), + ), + ); + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const legacyPath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + const legacy = yield* Effect.promise(async () => { + const stat = await NodeFSP.stat(transcript); + const file = { + s: stat.size, + m: stat.mtimeMs, + p: "claude", + o: stat.size, + gl: 0, + gh: 0, + cs: null, + t: [], + }; + // v5 ends with rateModelIndex at column 11; it has no TTL subset. + const timestamp = Date.parse("2026-08-01T10:00:00Z"); + const row = [timestamp, 0, 0, 0, 0, 10, 0, 0, "msg_1:req_1", null, 0, null]; + const text = encodeUnknownJsonString({ + version: 5, + models: ["claude-opus-5-5", "old-model-alias"], + sessions: ["session-1"], + files: { + [transcript]: { ...file, r: [row] }, + [NodePath.join(NodePath.dirname(transcript), "deleted.jsonl")]: { + ...file, + r: [[timestamp, 1, 0, 0, 0, 10, 0, 0, "deleted", null, 0, 0]], }, }, - })}\n`; - const deleted = NodePath.join(NodePath.dirname(transcript), "deleted.jsonl"); - yield* Effect.promise(async () => { - await NodeFSP.writeFile(transcript, cacheWriteLine(1)); - await NodeFSP.writeFile(deleted, cacheWriteLine(2)); - }); - - yield* Effect.gen(function* () { - const { stateDir } = yield* ServerConfig.ServerConfig; - const cachePath = NodePath.join(stateDir, "usage-scan-cache-v6.json"); - const legacyPath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); - yield* (yield* UsageService.make).readSummary(WINDOW); - - // Rewrite the cache as a v5 server left it: rows without 1-hour writes. - const legacy = yield* Effect.promise(async () => { - const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { - files: Record; - }; - for (const file of Object.values(document.files)) { - file.r = file.r.map((row) => row.slice(0, 11)); - } - const text = encodeUnknownJsonString({ ...document, version: 5 }); - await NodeFSP.writeFile(legacyPath, text); - await NodeFSP.rm(cachePath); - await NodeFSP.rm(deleted); - return text; }); - - const summary = yield* (yield* UsageService.make).readSummary(WINDOW); - // The live transcript re-parses at the 1-hour rate (10 x 2); the - // deleted one keeps its saved v5 usage at the 5-minute rate (10 x 1). - assert.strictEqual( - summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), - 30, - ); - assert.strictEqual( - yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), - legacy, - ); - }).pipe( - Effect.provide( - layerService({ - prefix: "usage-service-v5-upgrade-test", - home, - settings, - ratesDocument: { - "claude-opus-5-5": { - input_cost_per_token: 0, - output_cost_per_token: 0, - cache_creation_input_token_cost: 1, - cache_creation_input_token_cost_above_1hr: 2, - }, - }, - }), + await NodeFSP.mkdir(stateDir, { recursive: true }); + await NodeFSP.writeFile(legacyPath, text); + return text; + }); + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets.length, 2); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 26, + ); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.totals.cacheCreationTokens, 0), + 20, + ); + yield* service.awaitPersisted; + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, summary.buckets); + yield* restarted.awaitPersisted; + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy, + ); + assert.isTrue( + yield* (yield* FileSystem.FileSystem).exists( + NodePath.join(stateDir, "usage-scan-cache-v6.json"), ), ); - }).pipe(Effect.scoped), + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-v5-upgrade-test", + home, + settings, + ratesDocument: { + "claude-opus-5-5": { + input_cost_per_token: 1, + output_cost_per_token: 1, + cache_creation_input_token_cost: 1, + cache_creation_input_token_cost_above_1hr: 2, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), ); it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index b89c1fe702234..e8402377ad489 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -12,11 +12,13 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, + cacheCreation1hCostPerToken: 1.25e-5, fast: { inputCostPerToken: 2e-5, outputCostPerToken: 1e-4, cacheReadCostPerToken: 2e-6, cacheCreationCostPerToken: 2.5e-5, + cacheCreation1hCostPerToken: 2.5e-5, }, ultrafast: null, }, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 7f4586cc2bf45..5adcb42e80f32 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -47,7 +47,13 @@ describe("usage pricing", () => { }); for (const reportedCostUsd of [null, 99]) { - expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toMatchObject({ + expect( + priceUsage( + table, + { ...record("example-model", reportedCostUsd, "fast"), cacheCreation1hTokens: 750_000 }, + overrides, + ), + ).toMatchObject({ costUsd: 13.5, costSource: "modelPriced", }); @@ -137,14 +143,17 @@ describe("usage pricing", () => { ); }); - it("splits cost by category and prices the speed premium", () => { + it("splits mixed-TTL cache costs and speed premiums while preserving reported totals", () => { const table = parseRateTable({ "claude-opus-5-5": { ...rate(4e-6, 4e-7), cache_creation_input_token_cost: 5e-6, + cache_creation_input_token_cost_above_1hr: 8e-6, provider_specific_entry: { fast: 2 }, }, + "claude-sonnet-5-5": { ...rate(2e-6), cache_creation_input_token_cost: 2.5e-6 }, }); + const mixed = { ...record("claude-opus-5-5", null, "fast"), cacheCreation1hTokens: 750_000 }; const split = (input: number, cacheRead: number, cacheWrite: number, output: number) => ({ input: expect.closeTo(input), cacheRead: expect.closeTo(cacheRead), @@ -152,18 +161,22 @@ describe("usage pricing", () => { output: expect.closeTo(output), }); - expect(priceUsage(table, record("claude-opus-5-5", null, "fast"))).toEqual({ - costUsd: expect.closeTo(58.8), + expect(priceUsage(table, mixed)).toEqual({ + costUsd: expect.closeTo(63.3), costSource: "modelPriced", - categoryCostUsd: split(8, 0.8, 10, 40), - speedPremiumUsd: expect.closeTo(29.4), + categoryCostUsd: split(8, 0.8, 14.5, 40), + speedPremiumUsd: expect.closeTo(31.65), }); - expect(priceUsage(table, record("claude-opus-5-5", 29.4, "fast"))).toEqual({ - costUsd: 29.4, + expect(priceUsage(table, { ...mixed, reportedCostUsd: 31.65 })).toEqual({ + costUsd: 31.65, costSource: "providerReported", - categoryCostUsd: split(4, 0.4, 5, 20), - speedPremiumUsd: expect.closeTo(14.7), + categoryCostUsd: split(4, 0.4, 7.25, 20), + speedPremiumUsd: expect.closeTo(15.825), }); + expect( + priceUsage(table, { ...mixed, model: "claude-sonnet-5-5", speed: "standard" }).categoryCostUsd + ?.cacheWrite, + ).toBeCloseTo(2.5); expect(priceUsage(table, record("unknown-model", 29.4, "fast"))).toMatchObject({ costUsd: 29.4, categoryCostUsd: null, @@ -171,31 +184,12 @@ describe("usage pricing", () => { }); }); - it("prices 1-hour cache writes at their own rate", () => { - const table = parseRateTable({ - "claude-opus-5-5": { - ...rate(4e-6, 4e-7), - cache_creation_input_token_cost: 5e-6, - cache_creation_input_token_cost_above_1hr: 8e-6, - provider_specific_entry: { fast: 2 }, - }, - // No published 1-hour rate: those writes bill at the 5-minute rate. - "claude-sonnet-5-5": { ...rate(2e-6, 2e-7), cache_creation_input_token_cost: 2.5e-6 }, - }); - const cacheWrite = (model: string, cacheCreation1hTokens: number, speed: UsageSpeed) => - priceUsage(table, { ...record(model, null, speed), cacheCreation1hTokens }).categoryCostUsd - ?.cacheWrite; - - // 250k tokens at 5 per million plus 750k at 8. - expect(cacheWrite("claude-opus-5-5", 750_000, "standard")).toBeCloseTo(7.25); - expect(cacheWrite("claude-opus-5-5", 750_000, "fast")).toBeCloseTo(14.5); - expect(cacheWrite("claude-sonnet-5-5", 750_000, "standard")).toBeCloseTo(2.5); - }); - it("prices Codex priority and ultrafast requests at their published tier rates", () => { const table = parseRateTable({ "gpt-6-astra": { ...rate(1e-5, 1e-6), + cache_creation_input_token_cost: 5e-6, + cache_creation_input_token_cost_above_1hr: 8e-6, input_cost_per_token_priority: 2e-5, output_cost_per_token_priority: 1e-4, cache_read_input_token_cost_priority: 2e-6, @@ -205,7 +199,11 @@ describe("usage pricing", () => { "gpt-6-sol": rate(2e-6, 2e-7), }); const cost = (model: string, speed: UsageSpeed) => - priceUsage(table, record(model, null, speed)).costUsd; + priceUsage(table, { ...record(model, null, speed), cacheCreation1hTokens: 750_000 }).costUsd; + expect( + priceUsage(table, { ...record("gpt-6-astra", null, "fast"), cacheCreation1hTokens: 750_000 }) + .categoryCostUsd?.cacheWrite, + ).toBeCloseTo(14.5); const standard = cost("gpt-6-astra", "standard"); expect(cost("gpt-6-astra", "fast")).toBeCloseTo(2 * standard); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 8ee1cca6db3b2..74648f1c3d908 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -68,7 +68,8 @@ describe("scan cache round trip", () => { record(), record({ dedupeKey: "msg_2:", - model: "claude-opus-5-5", + model: "anthropic/claude-opus-5-5", + rateModel: "claude-opus-5-5", speed: "fast", cacheCreation1hTokens: 8, }), diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index e088c8bb20922..05af782195366 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -45,6 +45,7 @@ const claude = (id = "m1", output = 99) => ({ output_tokens: output, cache_read_input_tokens: 20, cache_creation_input_tokens: 5, + cache_creation: { ephemeral_1h_input_tokens: 3, ephemeral_5m_input_tokens: 2 }, speed: "fast", }, }, @@ -166,6 +167,7 @@ describe("large usage records", () => { cacheCreationTokens: 5, reasoningTokens: 0, }, + cacheCreation1hTokens: 3, reportedCostUsd: 0.25, speed: "fast", dedupeKey: "m1:r-m1", From 3754b62c6c89735e3a0bfd80d87f985c68719774 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:04:17 +0000 Subject: [PATCH 3/3] chore(server): clarify usage pricing and cache migration comments --- apps/server/src/usage/usagePricing.ts | 10 ++-------- apps/server/src/usage/usageScanCache.ts | 7 +++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index b949d292e2cce..124e21e4ca476 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -57,14 +57,8 @@ function finiteNumber(value: unknown): number | null { } /** - * Reads one rate set, `suffix` selecting a tier such as `_priority`. Returns - * `null` without both an input and an output rate. - * - * Anthropic bills cache reads at a discount and cache writes at a premium. - * When the standard tier omits them, cached input is priced as plain input - * rather than as free. A faster tier that omits them keeps the standard tier's - * cache-to-input ratio. A missing 1-hour write rate falls back to the 5-minute - * one. + * Missing cache rates use the input rate; faster tiers preserve the standard + * cache-to-input ratio. Missing 1-hour write rates use the 5-minute rate. */ function readTokenRates( entry: LiteLlmEntry, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c63abfdb49ba0..e3439531fde54 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -293,10 +293,9 @@ export function decodeScanCache( ) { continue; } - // v4 records of stateful formats (Codex) predate service tiers, so they - // all priced as standard. Keep them, because the rollout may be gone, but - // make a live rollout re-parse whole: no file has size -1, and a zero - // position cannot resume. + // Stateful formats before v5 lack service tiers; Claude before v6 lacks + // 1-hour cache writes. Keep deleted history, but force live files to reparse: + // no file has size -1, and a zero position cannot resume. const legacy = (format.state !== undefined && version < 5) || (entry.p === "claude" && version < 6); // A corrupt state disqualifies the entry: resuming with it would attach