From ac0777f2740aae67e46bf192d85f19897c38d9bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 17:19:20 +0000 Subject: [PATCH 1/2] test(server): Claude 1-hour cache writes are priced at the 1-hour rate Fails on main: the cache_creation TTL split is dropped, so one-hour writes are charged at the five-minute rate (Refs pingdotgg/t3code#21010). AI-assisted; verified with the tests and tools listed above. --- .../src/provider/Drivers/claudeUsage.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 6f457f4f4cf2f..3c9856c75bed5 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; +import { parseRateTable, priceUsage } from "../../usage/usagePricing.ts"; import { claudeUsageFormat, parseClaudeLine } from "./claudeUsage.ts"; function claudeLine(overrides: { @@ -216,3 +217,44 @@ describe("Claude advisor iterations", () => { expect(parse(unusable).map((record) => record.model)).toEqual(["claude-opus-5-5"]); }); }); + +describe("Claude cache write pricing", () => { + it("prices one-hour cache writes at the one-hour rate and the rest at the five-minute rate", () => { + const model = "claude-opus-4-5-20251101"; + const rates = parseRateTable({ + [model]: { + litellm_provider: "anthropic", + input_cost_per_token: 5e-6, + output_cost_per_token: 25e-6, + cache_read_input_token_cost: 5e-7, + cache_creation_input_token_cost: 6.25e-6, + cache_creation_input_token_cost_above_1hr: 1e-5, + }, + }); + const [record] = parseClaudeLine( + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "s", + message: { + id: "msg_ttl", + model, + usage: { + input_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_creation: { + ephemeral_5m_input_tokens: 400_000, + ephemeral_1h_input_tokens: 600_000, + }, + output_tokens: 0, + }, + }, + }), + ); + + if (record === undefined) throw new Error("expected a usage record"); + // 0.4M x $6.25/M + 0.6M x $10/M + expect(priceUsage(rates, record).costUsd).toBeCloseTo(8.5, 9); + }); +}); From 4af2cfd8cff006fd02aa23daa46ee6df9c361dd6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 17:27:18 +0000 Subject: [PATCH 2/2] fix(server): Claude 1-hour cache writes are priced at the 1-hour rate Usage read only cache_creation_input_tokens from Claude transcripts and only the 5-minute cache write rate from LiteLLM, so writes Claude Code made with the 1-hour TTL were understated (1M on Opus 4.5 showed $6.25, not $10). Claude records (executor and advisor) now carry the cache_creation.ephemeral_1h_input_tokens count, rates read cache_creation_input_token_cost_above_1hr (falling back to the 5-minute rate), and pricing charges that part of the cache writes at the 1-hour rate. The scan cache stores the count and moves to v13 so cached Claude entries re-parse once to pick it up. Fixes pingdotgg/t3code#21010 AI-assisted; verified with the tests and tools listed above. --- .../src/provider/Drivers/claudeUsage.test.ts | 6 +- .../src/provider/Drivers/claudeUsage.ts | 9 +++ apps/server/src/usage/UsageService.test.ts | 2 +- .../server/src/usage/usageAggregation.test.ts | 2 + apps/server/src/usage/usageAggregation.ts | 6 ++ apps/server/src/usage/usagePricing.ts | 74 ++++++++++++------- apps/server/src/usage/usageScanCache.test.ts | 16 +++- apps/server/src/usage/usageScanCache.ts | 27 ++++--- packages/provider-core/src/server/usage.ts | 2 + 9 files changed, 101 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 3c9856c75bed5..bc10ba5b7b29a 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -116,7 +116,8 @@ describe("Claude advisor iterations", () => { model: "claude-fable-5-1", input_tokens: 54204, cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, + cache_creation_input_tokens: 500, + cache_creation: { ephemeral_5m_input_tokens: 200, ephemeral_1h_input_tokens: 300 }, output_tokens: 4229, }, { @@ -161,10 +162,11 @@ describe("Claude advisor iterations", () => { totals: { uncachedInputTokens: 54204, cachedInputTokens: 0, - cacheCreationTokens: 0, + cacheCreationTokens: 500, outputTokens: 4229, reasoningTokens: 0, }, + cacheCreation1hTokens: 300, dedupeKey: expect.any(String), }, ]; diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index acb697d2941de..713b35884292b 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -30,6 +30,13 @@ export function parseClaudeLine(line: string): readonly UsageRecord[] { return parseClaudeRecord(parsed); } +function cacheCreation1h(usage: Record): { cacheCreation1hTokens?: number } { + const split = usage["cache_creation"]; + if (typeof split !== "object" || split === null) return {}; + const tokens = tokenCount((split as Record)["ephemeral_1h_input_tokens"]); + return tokens > 0 ? { cacheCreation1hTokens: tokens } : {}; +} + function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { if (typeof parsed !== "object" || parsed === null) return []; @@ -70,6 +77,7 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { // Anthropic folds thinking tokens into output and does not break them out. reasoningTokens: 0, }, + ...cacheCreation1h(usageRecord), reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, speed: usageRecord["speed"] === "fast" ? "fast" : "standard", dedupeKey, @@ -101,6 +109,7 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { outputTokens: tokenCount(advisor["output_tokens"]), reasoningTokens: 0, }, + ...cacheCreation1h(advisor), reportedCostUsd: null, speed: executor.speed, dedupeKey: dedupeKey === null ? null : `${dedupeKey}:advisor:${index}`, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ae0126d438254..3644a53bbc396 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2890,7 +2890,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v13.json"); assert.deepStrictEqual( (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) .filter((name) => name.startsWith("usage-scan-cache")) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 625f7a9d63eca..f99615085bd2a 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: 2e-5, fast: { inputCostPerToken: 2e-5, outputCostPerToken: 1e-4, cacheReadCostPerToken: 2e-6, cacheCreationCostPerToken: 2.5e-5, + cacheCreation1hCostPerToken: 4e-5, }, ultrafast: null, contextTiers: [], diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 8d1d799859737..9ff9cb9369462 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -147,12 +147,18 @@ export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRec b.cacheCreationTokens <= a.cacheCreationTokens && b.outputTokens <= a.outputTokens && b.reasoningTokens <= a.reasoningTokens && + (copy.cacheCreation1hTokens ?? 0) <= (held.cacheCreation1hTokens ?? 0) && reportedCostUsd === held.reportedCostUsd ) { return held; } + const cacheCreation1hTokens = Math.max( + held.cacheCreation1hTokens ?? 0, + copy.cacheCreation1hTokens ?? 0, + ); return { ...held, + ...(cacheCreation1hTokens > 0 ? { cacheCreation1hTokens } : {}), totals: { uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens), cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens), diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 4a04116db2df9..21a1bd02bb178 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -12,6 +12,8 @@ export interface TokenRates { readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; + /** Cache writes with a 1-hour TTL; the 5-minute rate when unpublished. */ + readonly cacheCreation1hCostPerToken: number; } interface SpeedRates extends TokenRates { @@ -37,20 +39,24 @@ 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, - contextTiers: [], - }, - ]), + 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, + contextTiers: [], + }, + ]; + }), ); } @@ -60,7 +66,7 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -// Missing cache rates fall back to input pricing; Anthropic normally discounts reads and charges extra for writes. +// Missing cache rates fall back to input pricing, and a missing 1-hour write rate to the 5-minute one. function readTokenRates( entry: LiteLlmEntry, suffix: string, @@ -70,18 +76,28 @@ function readTokenRates( const input = finiteNumber(entry[`input_cost_per_token${suffix}`]); const output = finiteNumber(entry[`output_cost_per_token${suffix}`]) ?? outputFallback ?? null; if (input === null || output === null) return null; - const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") => + const cacheRate = ( + name: string, + field: "cacheReadCostPerToken" | "cacheCreationCostPerToken" | "cacheCreation1hCostPerToken", + fallback = input, + ) => 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", + ); return { inputCostPerToken: input, outputCostPerToken: output, cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"), - cacheCreationCostPerToken: cacheRate( - "cache_creation_input_token_cost", - "cacheCreationCostPerToken", + cacheCreationCostPerToken, + cacheCreation1hCostPerToken: cacheRate( + "cache_creation_input_token_cost_above_1hr", + "cacheCreation1hCostPerToken", + cacheCreationCostPerToken, ), }; } @@ -92,6 +108,7 @@ function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates { outputCostPerToken: rates.outputCostPerToken * multiple, cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple, cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple, + cacheCreation1hCostPerToken: rates.cacheCreation1hCostPerToken * multiple, }; } @@ -192,7 +209,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 ); } @@ -301,7 +319,7 @@ function resolveRate(table: RateTable, model: string): ModelRate | null { export type PricedRecord = Pick< UsageRecord, - "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd" + "model" | "rateModel" | "totals" | "cacheCreation1hTokens" | "speed" | "reportedCostUsd" >; function recordRate(table: RateTable, record: PricedRecord): ModelRate | null { @@ -316,11 +334,15 @@ export interface PricedUsage { readonly speedPremiumUsd: number; } -function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost { +function costByCategory(record: PricedRecord, rates: TokenRates): UsageCategoryCost { + const { totals } = record; + const oneHour = 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 - oneHour) * rates.cacheCreationCostPerToken + + oneHour * rates.cacheCreation1hCostPerToken, output: totals.outputTokens * rates.outputCostPerToken, }; } @@ -352,13 +374,13 @@ export function priceUsage( return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported"); } - const listCost = costByCategory(totals, ratesAt(rate, record.speed, totals)); + const listCost = costByCategory(record, ratesAt(rate, record.speed, totals)); const listCostUsd = sumCategories(listCost); if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported"); const premiumUsd = record.speed === "standard" ? 0 - : listCostUsd - sumCategories(costByCategory(totals, ratesAt(rate, "standard", totals))); + : listCostUsd - sumCategories(costByCategory(record, ratesAt(rate, "standard", totals))); 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 2866a295da8fe..a8fba4da62ae6 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -65,7 +65,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", @@ -244,11 +252,11 @@ describe("scan cache round trip", () => { }, ); - it("re-parses a Claude transcript cached before partial copies were merged", () => { + it("re-parses a Claude transcript cached before 1-hour cache writes were counted", () => { const original = cacheWith([["/claude.jsonl", 100, [record()]]]); - const v11 = { ...parse(makeScanCacheWriter()(original, {})), version: 11 }; + const v12 = { ...parse(makeScanCacheWriter()(original, {})), version: 12 }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(v11)), TEST_FORMAT_MAP); + const restored = decodeScanCache(JSON.parse(JSON.stringify(v12)), TEST_FORMAT_MAP); expect(restored.get("/claude.jsonl")).toEqual({ ...original.get("/claude.jsonl"), size: -1, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 4cd959c75aa25..4f1137f3f7a1c 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -10,7 +10,7 @@ import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { mergeUsageCopies } from "./usageAggregation.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 12 as const; +const USAGE_SCAN_CACHE_VERSION = 13 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -18,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * the newer one; older files are removed once it is saved. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v13.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v12.json", "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", @@ -59,7 +60,8 @@ type SerializedRecord = readonly [ reportedCostUsd: number | null, speed: number, rateModelIndex?: number | null, - unresolvedServiceTierTurnId?: string, + unresolvedServiceTierTurnId?: string | null, + cacheCreation1hTokens?: number, ]; interface SerializedFile { @@ -118,9 +120,11 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile record.rateModel === undefined ? null : intern(tables.models, tables.modelIndex, record.rateModel), - ...(record.unresolvedServiceTierTurnId === undefined - ? ([] as const) - : ([record.unresolvedServiceTierTurnId] as const)), + ...(record.cacheCreation1hTokens !== undefined + ? ([record.unresolvedServiceTierTurnId ?? null, record.cacheCreation1hTokens] as const) + : record.unresolvedServiceTierTurnId !== undefined + ? ([record.unresolvedServiceTierTurnId] as const) + : ([] as const)), ]; return { s: entry.size, @@ -211,6 +215,7 @@ export function decodeScanCache( speedIndex, rateModelIndex, unresolvedServiceTierTurnId, + cacheCreation1h = 0, ] = row as SerializedRecord; const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; @@ -220,8 +225,8 @@ export function decodeScanCache( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || - (unresolvedServiceTierTurnId !== undefined && - typeof unresolvedServiceTierTurnId !== "string") || + (unresolvedServiceTierTurnId != null && typeof unresolvedServiceTierTurnId !== "string") || + !Number.isFinite(cacheCreation1h) || (rateModelIndex != null && rateModel === undefined) || !Number.isFinite(uncached) || !Number.isFinite(cached) || @@ -250,9 +255,10 @@ export function decodeScanCache( outputTokens: output, reasoningTokens: reasoning, }, + ...(cacheCreation1h > 0 ? { cacheCreation1hTokens: cacheCreation1h } : {}), reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, speed, - ...(version >= 7 && unresolvedServiceTierTurnId !== undefined + ...(version >= 7 && unresolvedServiceTierTurnId != null ? { unresolvedServiceTierTurnId } : {}), dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, @@ -292,8 +298,9 @@ export function decodeScanCache( // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. // v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy. + // v13 adds Claude 1-hour cache writes. const legacy = - (entry.p === "claude" && version < 12) || + (entry.p === "claude" && version < 13) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index eaed1d06ece4f..c2c75a641e53a 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -18,6 +18,8 @@ export interface UsageRecord { readonly rateModel?: string; readonly sessionId: string; readonly totals: UsageTokenTotals; + /** The part of `totals.cacheCreationTokens` written with a 1-hour TTL. */ + readonly cacheCreation1hTokens?: number; readonly reportedCostUsd: number | null; readonly speed: UsageSpeed; readonly unresolvedServiceTierTurnId?: string;