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.
This commit is contained in:
Jinwoo Hong
2026-10-18 19:55:00 -04:00
committed by GitHub
parent 966832793b
commit f8a5d3e284
12 changed files with 441 additions and 189 deletions
@@ -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<ClaudeUsageTokenTotals> = {
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
}
}
@@ -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
)
})
})
+80 -80
View File
@@ -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<ClaudeModelPricing>
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-<family>-<major>[-<minor>]`; `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<string, ClaudeModelPricing> = {
'claude-fable-5-1': FABLE_5_1_PRICING,
@@ -79,7 +85,7 @@ export const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
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<string, ClaudeModelPricing> = {
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<string, number | null>()
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
)
}
@@ -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<ClaudeBillableTokens>(addBillableTokens, NO_TOKENS)
const estimatedCostUsd = estimateCostUsd(session.model, totals)
return {
status: 'known',
@@ -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)
})
})
@@ -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<string, ClaudeUsageBreakdownRow>()
// Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type.
const cacheWrite1hTokensByKey = new Map<string, number>()
// 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<string, ClaudeBillableTokens>()
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)
}
}
@@ -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<typeof NodeOs>('node:os')),
homedir: homedirMock
}))
vi.mock('../claude-accounts/claude-profile-installed-router', () => ({
claudeProfileHistoryDirs: () => []
}))
type ScanResult = Awaited<ReturnType<typeof scanClaudeUsageFiles>>
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<ScanResult> {
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
)
})
})
+6 -2
View File
@@ -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 &&
+11 -4
View File
@@ -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(
@@ -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<ClaudeUsageTokenMaxima>): 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,
+6
View File
@@ -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 = {
+11 -2
View File
@@ -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<string, ClaudeUsageSession>,
@@ -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 })
})
}
}