From f8a5d3e2841c08fb2fe12e46991f66d3424473b7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:55:00 -0400 Subject: [PATCH] fix(usage): price Claude long-context requests per request, and price Haiku 5.5 (#30427) * fix(usage): price Claude long-context requests per request, and price Haiku 5.5 Haiku 5.5 bills a whole request at higher rates once its prompt (input plus cache reads and writes) exceeds 100K tokens. Claude usage was only kept as daily totals, so that could not be priced and Haiku 5.5 stayed unpriced. The existing Sonnet 4.5/4 200K tier had the opposite problem: it was applied as a marginal threshold to each day's totals, so a day of short requests could be surcharged and a single long request was under-charged (the Claude side of STA-8117, fixed for Codex in #22360). Each turn is now classified when it is aggregated, against its own model's threshold, and daily and per-location rows carry the long-context subset as an optional `longContext` field. A streamed turn that grows across the threshold moves its whole share in the resume correction. estimateCostUsd takes one token object and bills that subset at the long-context rates and the rest at base, replacing the per-bucket tiering. Saved per-file projections carry a projection version; files from an older version are reparsed on the next scan, so history picks up the split without touching the signed cache schema. * fix(usage): keep the projection version out of the integrity hash Hashing it made older inline reports fail validation and show the 'could not be validated' error; the scanner's version check alone decides reparse. --- .../claude-usage/claude-billable-tokens.ts | 72 ++++++++ .../claude-usage/claude-model-pricing.test.ts | 126 ++++++++------ src/main/claude-usage/claude-model-pricing.ts | 160 +++++++++--------- .../claude-usage-automation-attribution.ts | 30 +--- .../claude-usage-report-aggregation.test.ts | 23 ++- .../claude-usage-report-aggregation.ts | 29 +--- .../claude-usage/scanner-long-context.test.ts | 123 ++++++++++++++ src/main/claude-usage/scanner.ts | 8 +- src/main/claude-usage/store.test.ts | 15 +- .../transcript-usage-projection.ts | 25 ++- src/main/claude-usage/types.ts | 6 + src/main/claude-usage/usage-aggregation.ts | 13 +- 12 files changed, 441 insertions(+), 189 deletions(-) create mode 100644 src/main/claude-usage/claude-billable-tokens.ts create mode 100644 src/main/claude-usage/scanner-long-context.test.ts diff --git a/src/main/claude-usage/claude-billable-tokens.ts b/src/main/claude-usage/claude-billable-tokens.ts new file mode 100644 index 00000000000..f3379d2931d --- /dev/null +++ b/src/main/claude-usage/claude-billable-tokens.ts @@ -0,0 +1,72 @@ +import type { ClaudeUsageTokenTotals } from './types' + +/** Token totals plus the subset from requests over the model's long-context threshold. */ +export type ClaudeBillableTokens = ClaudeUsageTokenTotals & { + /** Absent when no request crossed the threshold. */ + longContext?: ClaudeUsageTokenTotals +} + +export function addTokenTotals( + left: ClaudeUsageTokenTotals, + right: ClaudeUsageTokenTotals, + sign: 1 | -1 = 1 +): ClaudeUsageTokenTotals { + return { + inputTokens: left.inputTokens + sign * right.inputTokens, + outputTokens: left.outputTokens + sign * right.outputTokens, + cacheReadTokens: left.cacheReadTokens + sign * right.cacheReadTokens, + cacheWriteTokens: left.cacheWriteTokens + sign * right.cacheWriteTokens, + cacheWrite1hTokens: left.cacheWrite1hTokens + sign * right.cacheWrite1hTokens + } +} + +export function minTokenTotals( + left: ClaudeUsageTokenTotals, + right: ClaudeUsageTokenTotals +): ClaudeUsageTokenTotals { + return { + inputTokens: Math.min(left.inputTokens, right.inputTokens), + outputTokens: Math.min(left.outputTokens, right.outputTokens), + cacheReadTokens: Math.min(left.cacheReadTokens, right.cacheReadTokens), + cacheWriteTokens: Math.min(left.cacheWriteTokens, right.cacheWriteTokens), + cacheWrite1hTokens: Math.min(left.cacheWrite1hTokens, right.cacheWrite1hTokens) + } +} + +/** Sums two token sets, keeping each long-context subset aligned with its totals. */ +export function addBillableTokens( + left: ClaudeBillableTokens, + right: ClaudeBillableTokens +): ClaudeBillableTokens { + const sum = addTokenTotals(left, right) + const longContext = + left.longContext && right.longContext + ? addTokenTotals(left.longContext, right.longContext) + : (left.longContext ?? right.longContext) + return longContext ? { ...sum, longContext } : sum +} + +export const NO_TOKENS: Readonly = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 +} + +/** Adds (or with `sign` -1 removes) a long-context share, dropping the field once it nets to zero. */ +export function addLongContext( + target: ClaudeBillableTokens, + share: ClaudeUsageTokenTotals | undefined, + sign: 1 | -1 = 1 +): void { + if (!share) { + return + } + const next = addTokenTotals(target.longContext ?? NO_TOKENS, share, sign) + if (Object.values(next).every((value) => value === 0)) { + delete target.longContext + } else { + target.longContext = next + } +} diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts index 48f1a0e31b9..b9aedf1de40 100644 --- a/src/main/claude-usage/claude-model-pricing.test.ts +++ b/src/main/claude-usage/claude-model-pricing.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from 'vitest' +import type { ClaudeUsageTokenTotals } from './types' import { MODEL_PRICING, estimateCostUsd, normalizeModelForPricing } from './claude-model-pricing' +const MTOK = 1_000_000 + +function tokens( + inputTokens = 0, + outputTokens = 0, + cacheReadTokens = 0, + cacheWriteTokens = 0, + cacheWrite1hTokens = 0 +): ClaudeUsageTokenTotals { + return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens } +} + +/** Prices plain totals, none of them from a long-context request. */ +function estimate(model: string, ...counts: number[]): number | null { + return estimateCostUsd(model, tokens(...counts)) +} + describe('model pricing name matching', () => { it.each(Object.keys(MODEL_PRICING))('resolves the table key %s to its own row', (key) => { expect(normalizeModelForPricing(key)).toBe(key) @@ -47,79 +65,87 @@ describe('model pricing name matching', () => { ])('leaves %s unpriced', (model) => { expect(normalizeModelForPricing(model)).toBeNull() }) - - it('leaves Haiku 5.5 unpriced until per-request prompt sizes are tracked', () => { - expect(normalizeModelForPricing('claude-haiku-5-5')).toBeNull() - }) }) describe('point-release rates that differ from their major', () => { it('bills Opus 5.5 below Opus 5 across every bucket', () => { - expect(estimateCostUsd('claude-opus-5-5', 1_000_000, 1_000_000, 1_000_000, 0)).toBeCloseTo(24.2) - expect(estimateCostUsd('claude-opus-5-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(6.2) + expect(estimate('claude-opus-5-5', 1_000_000, 1_000_000, 1_000_000, 0)).toBeCloseTo(24.2) + expect(estimate('claude-opus-5-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(6.2) }) it('bills Sonnet 5.5 cache reads at half of Sonnet 5', () => { - expect(estimateCostUsd('claude-sonnet-5-5', 0, 0, 1_000_000, 0)).toBeCloseTo(0.1) - expect(estimateCostUsd('claude-sonnet-5-5', 1_000_000, 1_000_000, 0, 1_000_000, 0)).toBeCloseTo( - 14.5 - ) - expect(estimateCostUsd('claude-sonnet-5-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) + expect(estimate('claude-sonnet-5-5', 0, 0, 1_000_000, 0)).toBeCloseTo(0.1) + expect(estimate('claude-sonnet-5-5', 1_000_000, 1_000_000, 0, 1_000_000, 0)).toBeCloseTo(14.5) + expect(estimate('claude-sonnet-5-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) }) it('bills Fable 5.1 cache reads at a quarter of Fable 5', () => { - expect(estimateCostUsd('claude-fable-5-1', 0, 0, 1_000_000, 0)).toBeCloseTo(0.25) - expect(estimateCostUsd('claude-fable-5', 0, 0, 1_000_000, 0)).toBeCloseTo(1) - expect(estimateCostUsd('claude-fable-5-1m', 0, 0, 1_000_000, 0)).toBeCloseTo(1) + expect(estimate('claude-fable-5-1', 0, 0, 1_000_000, 0)).toBeCloseTo(0.25) + expect(estimate('claude-fable-5', 0, 0, 1_000_000, 0)).toBeCloseTo(1) + expect(estimate('claude-fable-5-1m', 0, 0, 1_000_000, 0)).toBeCloseTo(1) }) }) describe('estimateCostUsd cache-write TTL rates', () => { it('bills 5-minute cache writes at 1.25x base input', () => { - expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25) - }) - - it('keeps the legacy five-argument call billing every write at the 5-minute rate', () => { - expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000)).toBeCloseTo(6.25) + expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25) }) it('bills 1-hour cache writes at 2x base input', () => { - expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10) + expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10) }) it('splits a mixed write bucket without double-billing the 1-hour share', () => { - expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75) + expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75) }) it('clamps a 1-hour count that exceeds the reported write total', () => { - expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01) + expect(estimate('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01) }) it('keeps Sonnet 4.6 one-hour writes flat across its full 1M window', () => { - expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(2.4) - }) - - it('applies the legacy long-context tier to Sonnet 4.5 one-hour writes', () => { - expect(estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6) - }) - - it('shares one legacy long-context allowance across both TTL buckets', () => { - // 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier - // getting half of the 200k allowance. - expect(estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925) - }) - - it('never lowers a legacy long-context estimate as writes shift to 1-hour', () => { - const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map((write1h) => - estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, write1h)! - ) - for (let index = 1; index < costs.length; index++) { - expect(costs[index]).toBeGreaterThan(costs[index - 1]) - } + expect(estimate('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(2.4) }) it('still returns null for unknown models', () => { - expect(estimateCostUsd('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull() + expect(estimate('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull() + }) +}) + +// A request over the threshold is billed entirely at long-context rates; callers pass that subset. +describe('estimateCostUsd long-context requests', () => { + it('bills only the long-context subset at Haiku 5.5 long-context rates', () => { + const short = tokens(MTOK, MTOK, MTOK, MTOK, 400_000) + expect(estimateCostUsd('claude-haiku-5-5', short)).toBeCloseTo(0.1 + 0.5 + 0.01 + 0.075 + 0.08) + expect(estimateCostUsd('claude-haiku-5-5', { ...short, longContext: short })).toBeCloseTo( + 0.5 + 2.5 + 0.05 + 0.375 + 0.4 + ) + expect( + estimateCostUsd('claude-haiku-5-5', { + ...tokens(2 * MTOK), + longContext: tokens(MTOK) + }) + ).toBeCloseTo(0.1 + 0.5) + }) + + it('bills Sonnet 4.5 long-context requests at its premium rates', () => { + const long = tokens(MTOK, MTOK, MTOK, MTOK, MTOK) + expect(estimateCostUsd('claude-sonnet-4-5', { ...long, longContext: long })).toBeCloseTo( + 6 + 22.5 + 0.6 + 12 + ) + // Daily totals far over 200K from short requests stay at base rates. + expect(estimate('claude-sonnet-4-5', 5 * MTOK)).toBeCloseTo(15) + }) + + it('ignores a long-context subset for a model with flat rates', () => { + const long = tokens(MTOK) + expect(estimateCostUsd('claude-sonnet-4-6', { ...long, longContext: long })).toBeCloseTo(3) + }) + + it('clamps a long-context subset larger than its totals', () => { + expect( + estimateCostUsd('claude-haiku-5-5', { ...tokens(MTOK), longContext: tokens(3 * MTOK) }) + ).toBeCloseTo(0.5) }) }) @@ -130,25 +156,25 @@ describe('estimateCostUsd cache-write TTL rates', () => { // is date-independent. describe('estimateCostUsd Claude Sonnet 5 rates', () => { it('bills base input at $2/MTok', () => { - expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2) + expect(estimate('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2) }) it('bills output at $10/MTok', () => { - expect(estimateCostUsd('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10) + expect(estimate('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10) }) it('bills cache hits at $0.20/MTok', () => { - expect(estimateCostUsd('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2) + expect(estimate('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2) }) it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => { - expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5) - expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) + expect(estimate('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5) + expect(estimate('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) }) it('keeps the full 1M window at flat rates with no long-context tier', () => { - expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo( - estimateCostUsd('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2 + expect(estimate('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo( + estimate('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2 ) }) }) diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts index 105ecfb1740..ae1be16bc82 100644 --- a/src/main/claude-usage/claude-model-pricing.ts +++ b/src/main/claude-usage/claude-model-pricing.ts @@ -1,4 +1,12 @@ -type ClaudeModelPricing = { +import { + addTokenTotals, + minTokenTotals, + NO_TOKENS, + type ClaudeBillableTokens +} from './claude-billable-tokens' +import type { ClaudeUsageTokenTotals } from './types' + +type ClaudeTokenRates = { input: number output: number cacheRead: number @@ -6,23 +14,23 @@ type ClaudeModelPricing = { cacheWrite: number /** 1-hour TTL cache write (2x base input). */ cacheWrite1h: number - thresholdTokens?: number - inputAboveThreshold?: number - outputAboveThreshold?: number - cacheReadAboveThreshold?: number - cacheWriteAboveThreshold?: number - cacheWrite1hAboveThreshold?: number } -const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 -const SONNET_LONG_CONTEXT_PRICING = { - thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, - inputAboveThreshold: 6, - outputAboveThreshold: 22.5, - cacheReadAboveThreshold: 0.6, - cacheWriteAboveThreshold: 7.5, - cacheWrite1hAboveThreshold: 12 -} satisfies Partial +type ClaudeModelPricing = ClaudeTokenRates & { + /** Rates for a whole request whose prompt (input plus cache reads and writes) exceeds + * `thresholdTokens`. Why: turns are classified when scanned, so adding or changing a tier needs + * CLAUDE_USAGE_PROJECTION_VERSION bumped for saved history to rescan. */ + longContext?: ClaudeTokenRates & { thresholdTokens: number } +} + +const SONNET_4_LONG_CONTEXT = { + thresholdTokens: 200_000, + input: 6, + output: 22.5, + cacheRead: 0.6, + cacheWrite: 7.5, + cacheWrite1h: 12 +} // Mythos models are billed at their Fable counterparts' rates. const FABLE_5_1_PRICING: ClaudeModelPricing = { @@ -43,8 +51,6 @@ const FABLE_5_PRICING: ClaudeModelPricing = { /** * Keyed by `claude--[-]`; `normalizeModelForPricing` resolves only exact * keys, so a new model stays unpriced (and flagged in the UI) until its row lands here. - * Why no `claude-haiku-5-5`: it reprices a whole request whose prompt exceeds 100K tokens, and - * these estimates run on daily totals that cannot tell which requests crossed that line. */ export const MODEL_PRICING: Record = { 'claude-fable-5-1': FABLE_5_1_PRICING, @@ -79,7 +85,7 @@ export const MODEL_PRICING: Record = { cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6, - ...SONNET_LONG_CONTEXT_PRICING + longContext: SONNET_4_LONG_CONTEXT }, 'claude-sonnet-4': { input: 3, @@ -87,10 +93,25 @@ export const MODEL_PRICING: Record = { cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6, - ...SONNET_LONG_CONTEXT_PRICING + longContext: SONNET_4_LONG_CONTEXT }, 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-haiku-5-5': { + input: 0.1, + output: 0.5, + cacheRead: 0.01, + cacheWrite: 0.125, + cacheWrite1h: 0.2, + longContext: { + thresholdTokens: 100_000, + input: 0.5, + output: 2.5, + cacheRead: 0.05, + cacheWrite: 0.625, + cacheWrite1h: 1 + } + }, 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, cacheWrite1h: 1.6 }, 'claude-haiku-3': { @@ -138,76 +159,55 @@ export function normalizeModelForPricing(model: string | null): string | null { return key !== null && Object.hasOwn(MODEL_PRICING, key) ? key : null } -function calculateTieredCost( - tokens: number, - basePrice: number, - abovePrice?: number, - threshold?: number -): number { - if (threshold === undefined || abovePrice === undefined) { - return tokens * basePrice +const longContextThresholdByModel = new Map() + +function longContextThresholdTokens(model: string | null): number | null { + if (model === null) { + return null } - const belowTokens = Math.min(tokens, threshold) - const aboveTokens = Math.max(tokens - threshold, 0) - return belowTokens * basePrice + aboveTokens * abovePrice + let threshold = longContextThresholdByModel.get(model) + if (threshold === undefined) { + const key = normalizeModelForPricing(model) + threshold = key === null ? null : (MODEL_PRICING[key].longContext?.thresholdTokens ?? null) + longContextThresholdByModel.set(model, threshold) + } + return threshold } -/** - * @param cacheWriteTokens every cache write, both TTLs - * @param cacheWrite1hTokens the 1-hour-TTL subset of `cacheWriteTokens` - */ -export function estimateCostUsd( +function priceTokens(rates: ClaudeTokenRates, tokens: ClaudeUsageTokenTotals): number { + const write1hTokens = Math.min(Math.max(tokens.cacheWrite1hTokens, 0), tokens.cacheWriteTokens) + return ( + tokens.inputTokens * rates.input + + tokens.outputTokens * rates.output + + tokens.cacheReadTokens * rates.cacheRead + + (tokens.cacheWriteTokens - write1hTokens) * rates.cacheWrite + + write1hTokens * rates.cacheWrite1h + ) +} + +/** The whole turn when its prompt exceeds `model`'s long-context threshold. */ +export function longContextTurnTokens( model: string | null, - inputTokens: number, - outputTokens: number, - cacheReadTokens: number, - cacheWriteTokens: number, - cacheWrite1hTokens = 0 -): number | null { + turn: ClaudeUsageTokenTotals +): ClaudeUsageTokenTotals | undefined { + const threshold = longContextThresholdTokens(model) + const prompt = turn.inputTokens + turn.cacheReadTokens + turn.cacheWriteTokens + return threshold !== null && prompt > threshold ? addTokenTotals(NO_TOKENS, turn) : undefined +} + +export function estimateCostUsd(model: string | null, tokens: ClaudeBillableTokens): number | null { const normalized = normalizeModelForPricing(model) if (!normalized) { return null } const pricing = MODEL_PRICING[normalized] - const write1hTokens = Math.min(Math.max(cacheWrite1hTokens, 0), cacheWriteTokens) - // Why: both TTL buckets share one long-context allowance. Giving each its own - // would make a split bucket cheaper than the same tokens billed entirely at 5m. - const write1hShare = cacheWriteTokens > 0 ? write1hTokens / cacheWriteTokens : 0 - const write5mThreshold = - pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * (1 - write1hShare) - const write1hThreshold = - pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * write1hShare + if (!pricing.longContext || !tokens.longContext) { + return priceTokens(pricing, tokens) / 1_000_000 + } + const longTokens = minTokenTotals(tokens.longContext, tokens) return ( - (calculateTieredCost( - inputTokens, - pricing.input, - pricing.inputAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - outputTokens, - pricing.output, - pricing.outputAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - cacheReadTokens, - pricing.cacheRead, - pricing.cacheReadAboveThreshold, - pricing.thresholdTokens - ) + - calculateTieredCost( - cacheWriteTokens - write1hTokens, - pricing.cacheWrite, - pricing.cacheWriteAboveThreshold, - write5mThreshold - ) + - calculateTieredCost( - write1hTokens, - pricing.cacheWrite1h, - pricing.cacheWrite1hAboveThreshold, - write1hThreshold - )) / + (priceTokens(pricing, addTokenTotals(tokens, longTokens, -1)) + + priceTokens(pricing.longContext, longTokens)) / 1_000_000 ) } diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts index cdbfb5fb916..feedb6471b8 100644 --- a/src/main/claude-usage/claude-usage-automation-attribution.ts +++ b/src/main/claude-usage/claude-usage-automation-attribution.ts @@ -1,6 +1,7 @@ import type { AutomationRunUsage } from '../../shared/automations-types' import type { ClaudeUsagePersistedState } from './types' import { estimateCostUsd } from './claude-model-pricing' +import { addBillableTokens, NO_TOKENS, type ClaudeBillableTokens } from './claude-billable-tokens' import { shouldForceAutomationUsageScan } from '../usage/automation-usage-scan-forcing' const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 @@ -95,33 +96,8 @@ export async function resolveAutomationRunUsage( (entry) => entry.worktreeId === input.worktreeId ) const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown - const totals = locations.reduce( - (acc, entry) => { - acc.turns += entry.turnCount - acc.inputTokens += entry.inputTokens - acc.outputTokens += entry.outputTokens - acc.cacheReadTokens += entry.cacheReadTokens - acc.cacheWriteTokens += entry.cacheWriteTokens - acc.cacheWrite1hTokens += entry.cacheWrite1hTokens - return acc - }, - { - turns: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - cacheWrite1hTokens: 0 - } - ) - const estimatedCostUsd = estimateCostUsd( - session.model, - totals.inputTokens, - totals.outputTokens, - totals.cacheReadTokens, - totals.cacheWriteTokens, - totals.cacheWrite1hTokens - ) + const totals = locations.reduce(addBillableTokens, NO_TOKENS) + const estimatedCostUsd = estimateCostUsd(session.model, totals) return { status: 'known', diff --git a/src/main/claude-usage/claude-usage-report-aggregation.test.ts b/src/main/claude-usage/claude-usage-report-aggregation.test.ts index 4527af28c23..7dea56e2d6a 100644 --- a/src/main/claude-usage/claude-usage-report-aggregation.test.ts +++ b/src/main/claude-usage/claude-usage-report-aggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { ClaudeUsageDailyAggregate, ClaudeUsagePersistedState } from './types' -import { buildSummary } from './claude-usage-report-aggregation' +import { buildBreakdown, buildSummary } from './claude-usage-report-aggregation' function dailyRow(day: string, model: string): ClaudeUsageDailyAggregate { return { @@ -93,3 +93,24 @@ describe('buildSummary unpriced models', () => { expect(summary.hasUnpricedModels).toBe(false) }) }) + +describe('buildSummary Haiku 5.5 long-context requests', () => { + it('bills only the requests recorded over 100K at the long-context rates', () => { + const row = { + ...dailyRow('2026-10-15', 'claude-haiku-5-5'), + longContext: { + inputTokens: 500_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 + } + } + const state = stateWithDaily([row]) + + // 500K input at $0.10 + 500K at $0.50 + 1M output at $0.50. + expect(buildSummary(state, 'all', 'all').estimatedCostUsd).toBeCloseTo(0.8) + expect(buildSummary(state, 'all', 'all').hasUnpricedModels).toBe(false) + expect(buildBreakdown(state, 'all', 'all', 'model')[0]?.estimatedCostUsd).toBeCloseTo(0.8) + }) +}) diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts index 61d183b5ad9..dd347596e57 100644 --- a/src/main/claude-usage/claude-usage-report-aggregation.ts +++ b/src/main/claude-usage/claude-usage-report-aggregation.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/claude-usage-types' import type { ClaudeUsagePersistedState } from './types' import { estimateCostUsd } from './claude-model-pricing' +import { addBillableTokens, NO_TOKENS, type ClaudeBillableTokens } from './claude-billable-tokens' import { getFilteredDaily, getFilteredSessions } from './claude-usage-scope-filters' export function buildSummary( @@ -44,14 +45,7 @@ export function buildSummary( row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.inputTokens + row.outputTokens ) - const cost = estimateCostUsd( - row.model, - row.inputTokens, - row.outputTokens, - row.cacheReadTokens, - row.cacheWriteTokens, - row.cacheWrite1hTokens - ) + const cost = estimateCostUsd(row.model, row) if (cost !== null) { hasAnyBillableCost = true estimatedCostUsd += cost @@ -116,8 +110,9 @@ export function buildBreakdown( kind: ClaudeUsageBreakdownKind ): ClaudeUsageBreakdownRow[] { const rows = new Map() - // Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type. - const cacheWrite1hTokensByKey = new Map() + // Why: the 1h cache-write split and long-context subset only feed pricing, so they stay off the + // renderer row type. + const billableByKey = new Map() const filteredDaily = getFilteredDaily(state, scope, range) const filteredSessions = getFilteredSessions(state, scope, range) @@ -140,10 +135,7 @@ export function buildBreakdown( existing.outputTokens += daily.outputTokens existing.cacheReadTokens += daily.cacheReadTokens existing.cacheWriteTokens += daily.cacheWriteTokens - cacheWrite1hTokensByKey.set( - key, - (cacheWrite1hTokensByKey.get(key) ?? 0) + daily.cacheWrite1hTokens - ) + billableByKey.set(key, addBillableTokens(billableByKey.get(key) ?? NO_TOKENS, daily)) rows.set(key, existing) } @@ -174,14 +166,7 @@ export function buildBreakdown( for (const row of rows.values()) { if (kind === 'model') { - row.estimatedCostUsd = estimateCostUsd( - row.key, - row.inputTokens, - row.outputTokens, - row.cacheReadTokens, - row.cacheWriteTokens, - cacheWrite1hTokensByKey.get(row.key) ?? 0 - ) + row.estimatedCostUsd = estimateCostUsd(row.key, billableByKey.get(row.key) ?? NO_TOKENS) } } diff --git a/src/main/claude-usage/scanner-long-context.test.ts b/src/main/claude-usage/scanner-long-context.test.ts new file mode 100644 index 00000000000..0945e43caa2 --- /dev/null +++ b/src/main/claude-usage/scanner-long-context.test.ts @@ -0,0 +1,123 @@ +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { scanClaudeUsageFiles } from './scanner' +import type { ClaudeUsagePersistedFile } from './types' +import { MIN_RESUMABLE_PREFIX_BYTES } from '../usage/jsonl-file-checkpoint' + +const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) + +vi.mock('node:os', async () => ({ + ...(await vi.importActual('node:os')), + homedir: homedirMock +})) + +vi.mock('../claude-accounts/claude-profile-installed-router', () => ({ + claudeProfileHistoryDirs: () => [] +})) + +type ScanResult = Awaited> + +let root: string +let transcriptPath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-usage-long-context-')) + homedirMock.mockReturnValue(root) + const projectDir = join(root, '.claude', 'projects', 'project-a') + await mkdir(projectDir, { recursive: true }) + transcriptPath = join(projectDir, 'long-context-session.jsonl') + vi.resetModules() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function assistantRow(key: string, model: string, cacheReadTokens: number): string { + return `${JSON.stringify({ + type: 'assistant', + sessionId: 'long-context-session', + timestamp: '2026-10-09T12:00:00.000Z', + cwd: join(root, 'workspace'), + requestId: `request-${key}`, + message: { + id: `message-${key}`, + model, + usage: { input_tokens: 1_000, output_tokens: 500, cache_read_input_tokens: cacheReadTokens } + } + })}\n` +} + +// Why: a scan only saves a resume checkpoint past MIN_RESUMABLE_PREFIX_BYTES. +function padding(): string { + return `${JSON.stringify({ type: 'user', content: 'x'.repeat(4 * MIN_RESUMABLE_PREFIX_BYTES) })}\n` +} + +async function scan(previous: ClaudeUsagePersistedFile[] = []): Promise { + const { scanClaudeUsageFiles } = await import('./scanner') + return scanClaudeUsageFiles([], previous, undefined, []) +} + +describe('Claude usage long-context subsets', () => { + it('records only requests whose prompt crosses their own model threshold', async () => { + await writeFile( + transcriptPath, + assistantRow('haiku-long', 'claude-haiku-5-5', 100_000) + + assistantRow('haiku-short', 'claude-haiku-5-5', 98_000) + + assistantRow('sonnet-short', 'claude-sonnet-4-5', 150_000) + + assistantRow('flat', 'claude-sonnet-4-6', 500_000) + ) + + const result = await scan() + + const byModel = new Map(result.dailyAggregates.map((row) => [row.model, row])) + expect(byModel.get('claude-haiku-5-5')?.longContext).toEqual({ + inputTokens: 1_000, + outputTokens: 500, + cacheReadTokens: 100_000, + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 + }) + expect(byModel.get('claude-haiku-5-5')?.cacheReadTokens).toBe(198_000) + expect(byModel.get('claude-sonnet-4-5')?.longContext).toBeUndefined() + expect(byModel.get('claude-sonnet-4-6')?.longContext).toBeUndefined() + expect(result.sessions[0]?.locationBreakdown[0]?.longContext?.cacheReadTokens).toBe(100_000) + }) + + it('moves a resumed turn into the long-context subset when its usage grows past the threshold', async () => { + await writeFile(transcriptPath, padding() + assistantRow('turn', 'claude-haiku-5-5', 90_000)) + const first = await scan() + expect(first.dailyAggregates[0]?.longContext).toBeUndefined() + const persisted: ClaudeUsagePersistedFile[] = JSON.parse(JSON.stringify(first.processedFiles)) + expect(persisted[0]?.parseResumeState).toBeTruthy() + await appendFile(transcriptPath, assistantRow('turn', 'claude-haiku-5-5', 120_000)) + + const resumed = await scan(persisted) + const cold = await scan() + + expect(resumed.dailyAggregates[0]?.longContext?.cacheReadTokens).toBe(120_000) + expect(resumed.dailyAggregates).toEqual(cold.dailyAggregates) + expect(resumed.sessions).toEqual(cold.sessions) + }) + + it('reparses a file saved by an older projection version', async () => { + await writeFile(transcriptPath, assistantRow('turn', 'claude-haiku-5-5', 120_000)) + const first = await scan() + const legacy = first.processedFiles.map( + ({ projectionVersion: _projectionVersion, ...file }) => ({ + ...file, + dailyAggregates: file.dailyAggregates.map(({ longContext: _longContext, ...row }) => row) + }) + ) + + const rebuilt = await scan(legacy) + + expect(rebuilt.dailyAggregates[0]?.longContext?.cacheReadTokens).toBe(120_000) + expect(rebuilt.processedFiles[0]?.projectionVersion).toBe( + first.processedFiles[0]?.projectionVersion + ) + }) +}) diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index 913fe6e0f9c..4db42a2552c 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -16,7 +16,10 @@ import { mergeClaudeDailyAggregates, mergeClaudeSessions } from './usage-aggregation' -import { projectClaudeUsageScanFile } from './transcript-usage-projection' +import { + CLAUDE_USAGE_PROJECTION_VERSION, + projectClaudeUsageScanFile +} from './transcript-usage-projection' import { normalizeClaudeUsageSourceFiles, type ClaudeUsageVerifiedSources @@ -41,7 +44,8 @@ async function planClaudeUsageFile( Array.isArray(previous.sessions) && Array.isArray(previous.dailyAggregates) && Array.isArray(previous.ownedDedupeKeys) && - typeof previous.hasDeferredClaims === 'boolean' + typeof previous.hasDeferredClaims === 'boolean' && + previous.projectionVersion === CLAUDE_USAGE_PROJECTION_VERSION const previousId = previous?.physicalFileId ?? previous?.parseResumeState?.physicalFileId const reusable = Boolean( validProjection && diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 927c61c5a17..0a5ea95a013 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -480,17 +480,24 @@ describe('ClaudeUsageStore', () => { outputTokens: 300_000, cacheReadTokens: 300_000, cacheWriteTokens: 300_000, - cacheWrite1hTokens: 0 + cacheWrite1hTokens: 0, + longContext: { + inputTokens: 300_000, + outputTokens: 300_000, + cacheReadTokens: 300_000, + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 + } })) }) const breakdown = await store.getBreakdown('orca', '30d', 'model') - // Why: the 4.5 tier premium only survives if `-4-5-` never matches the `-5` - // family regex, so this doubles as the digit-boundary proof for both families. + // Why: the 4.5 long-context premium only applies if `-4-5-` never matches the `-5` + // family pattern, so this doubles as the digit-boundary proof for both families. expect( breakdown.find((row) => row.key === 'claude-sonnet-4-5-20250929')?.estimatedCostUsd - ).toBeCloseTo(8.07) + ).toBeCloseTo(10.98) // Why: Opus 4.5 and Opus 5 share rates today, so this pins the rate rather // than the routing — it fails only if the two ever diverge. expect( diff --git a/src/main/claude-usage/transcript-usage-projection.ts b/src/main/claude-usage/transcript-usage-projection.ts index a508c568f88..68b533ae698 100644 --- a/src/main/claude-usage/transcript-usage-projection.ts +++ b/src/main/claude-usage/transcript-usage-projection.ts @@ -2,7 +2,9 @@ import type { ClaudeUsageAttributedTurn, ClaudeUsageDailyAggregate, ClaudeUsagePersistedFile, - ClaudeUsageSession + ClaudeUsageSession, + ClaudeUsageTokenMaxima, + ClaudeUsageTokenTotals } from './types' import type { ClaudeUsageParsedSourceTurn, ClaudeUsageScanFile } from './transcript-record-parser' import { @@ -17,6 +19,21 @@ import { type ClaudeUsageTokenCorrection } from './transcript-token-checkpoint' import { buildClaudeUsageProjectionIntegrity } from './transcript-projection-integrity' +import { addLongContext } from './claude-billable-tokens' +import { longContextTurnTokens } from './claude-model-pricing' + +/** Bump when a change to projected rows needs saved files reparsed (v2: long-context subsets). */ +export const CLAUDE_USAGE_PROJECTION_VERSION = 2 + +function maximaTotals(row: Readonly): ClaudeUsageTokenTotals { + return { + inputTokens: row[0], + outputTokens: row[1], + cacheReadTokens: row[2], + cacheWriteTokens: row[3], + cacheWrite1hTokens: row[4] + } +} function dailyKey(day: string, model: string | null, projectKey: string): string { return [day, model ?? 'unknown', projectKey].join('::') @@ -49,12 +66,17 @@ function applyTokenCorrection( session.totalCacheReadTokens += read session.totalCacheWriteTokens += write session.totalCacheWrite1hTokens += write1h + // Why: a growing turn can cross the long-context threshold, so its whole share moves at once. + const previousLong = longContextTurnTokens(projection.model, maximaTotals(previous)) + const updatedLong = longContextTurnTokens(projection.model, maximaTotals(updated)) for (const target of [location, aggregate]) { target.inputTokens += input target.outputTokens += output target.cacheReadTokens += read target.cacheWriteTokens += write target.cacheWrite1hTokens += write1h + addLongContext(target, previousLong, -1) + addLongContext(target, updatedLong) } aggregate.zeroCacheReadTurnCount += Number(updated[2] === 0) - Number(previous[2] === 0) } @@ -159,6 +181,7 @@ export async function projectClaudeUsageScanFile( ), ownedDedupeKeys: owned.ownedDedupeKeys, hasDeferredClaims, + projectionVersion: CLAUDE_USAGE_PROJECTION_VERSION, parseResumeState: read.checkpoint ? { ...read.checkpoint, diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index 7e06824260c..ecd60d713d0 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -18,6 +18,8 @@ export type ClaudeUsageLocationBreakdown = { cacheReadTokens: number cacheWriteTokens: number cacheWrite1hTokens: number + /** Subset of the totals from requests over the model's long-context threshold; see ClaudeBillableTokens. */ + longContext?: ClaudeUsageTokenTotals } export type ClaudeUsageSession = { @@ -52,6 +54,8 @@ export type ClaudeUsageDailyAggregate = { cacheReadTokens: number cacheWriteTokens: number cacheWrite1hTokens: number + /** Subset of the totals from requests over the model's long-context threshold; see ClaudeBillableTokens. */ + longContext?: ClaudeUsageTokenTotals } export type ClaudeUsagePersistedState = { @@ -81,6 +85,8 @@ export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & { * entire transcript corpus. */ hasDeferredClaims: boolean parseResumeState?: ClaudeUsageParseResumeState | null + /** CLAUDE_USAGE_PROJECTION_VERSION that built `sessions` and `dailyAggregates`; any other value reparses the file. */ + projectionVersion?: number } export type ClaudeUsageParsedTurn = { diff --git a/src/main/claude-usage/usage-aggregation.ts b/src/main/claude-usage/usage-aggregation.ts index 7626845827a..480285c930f 100644 --- a/src/main/claude-usage/usage-aggregation.ts +++ b/src/main/claude-usage/usage-aggregation.ts @@ -4,6 +4,8 @@ import type { ClaudeUsageLocationBreakdown, ClaudeUsageSession } from './types' +import { addLongContext } from './claude-billable-tokens' +import { longContextTurnTokens } from './claude-model-pricing' export function mergeClaudeSessions( target: Map, @@ -43,6 +45,7 @@ export function mergeClaudeSessions( existingLocation.cacheReadTokens += location.cacheReadTokens existingLocation.cacheWriteTokens += location.cacheWriteTokens existingLocation.cacheWrite1hTokens += location.cacheWrite1hTokens + addLongContext(existingLocation, location.longContext) } else { existing.locationBreakdown.push({ ...location }) } @@ -68,6 +71,7 @@ export function mergeClaudeDailyAggregates( existing.cacheReadTokens += aggregate.cacheReadTokens existing.cacheWriteTokens += aggregate.cacheWriteTokens existing.cacheWrite1hTokens += aggregate.cacheWrite1hTokens + addLongContext(existing, aggregate.longContext) } } @@ -122,6 +126,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { } const session = sessionsById.get(turn.sessionId)! + const longContext = longContextTurnTokens(turn.model, turn) if (turn.timestamp < session.firstTimestamp) { session.firstTimestamp = turn.timestamp } @@ -147,6 +152,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { location.cacheReadTokens += turn.cacheReadTokens location.cacheWriteTokens += turn.cacheWriteTokens location.cacheWrite1hTokens += turn.cacheWrite1hTokens + addLongContext(location, longContext) } else { session.locationBreakdown.push({ locationKey: turn.projectKey, @@ -158,7 +164,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, cacheWriteTokens: turn.cacheWriteTokens, - cacheWrite1hTokens: turn.cacheWrite1hTokens + cacheWrite1hTokens: turn.cacheWrite1hTokens, + ...(longContext && { longContext }) }) } @@ -174,6 +181,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { existingDaily.cacheReadTokens += turn.cacheReadTokens existingDaily.cacheWriteTokens += turn.cacheWriteTokens existingDaily.cacheWrite1hTokens += turn.cacheWrite1hTokens + addLongContext(existingDaily, longContext) } else { dailyByKey.set(dailyKey, { day: turn.day, @@ -188,7 +196,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, cacheWriteTokens: turn.cacheWriteTokens, - cacheWrite1hTokens: turn.cacheWrite1hTokens + cacheWrite1hTokens: turn.cacheWrite1hTokens, + ...(longContext && { longContext }) }) } }