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 }) }) } }