mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user