feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822)

* feat(usage): price Claude 5 family and GPT-5.6 token usage

Claude Opus 5, Sonnet 5, Fable 5 and Codex gpt-5.6 sol/terra/luna were
absent from the usage pricing tables, so their turns aggregated tokens
but reported no estimated cost.

Rates from Anthropic and OpenAI published pricing. Sonnet 5 gets no
long-context tier: Claude 4.6 and later bill the full 1M window flat.

Sonnet 5 uses the standard $3/$15 rate, not the $2/$10 introductory rate
that runs through 2026-08-31 — the table has no date dimension.

* fix(usage): price the bare gpt-5.6 alias and assert Opus 4.5 separately

OpenAI routes the bare `gpt-5.6` alias to Sol, but only the explicit
`-sol` / `-terra` / `-luna` IDs resolved, so alias-recorded sessions still
reported no cost. Match it exactly rather than by prefix so it cannot
swallow the tier IDs or a future cheaper variant.

Also split the Claude 5 shadowing guard into per-model breakdown
assertions and add the missing Opus 4.5 fixture the test name claimed.

* docs(usage): note Sonnet 5 uses standard, not introductory, rates
This commit is contained in:
Brennan Benson 2026-07-27 12:06:23 -07:00 committed by GitHub
parent 05603a2e78
commit cf513adddc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 258 additions and 0 deletions

View File

@ -254,6 +254,125 @@ describe('ClaudeUsageStore', () => {
).toBeCloseTo(36.75)
})
it('prices Claude 5 family models with current Anthropic rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-opus-5',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 1_000_000
},
{
day: '2026-04-09',
model: 'anthropic/claude-fable-5',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 1_000_000
},
{
day: '2026-04-09',
model: 'claude-sonnet-5-thinking',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 1_000_000
}
]
})
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(breakdown.find((row) => row.key === 'claude-opus-5')?.estimatedCostUsd).toBeCloseTo(
36.75
)
expect(
breakdown.find((row) => row.key === 'anthropic/claude-fable-5')?.estimatedCostUsd
).toBeCloseTo(73.5)
expect(
breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd
).toBeCloseTo(22.05)
})
it('prices Sonnet 5 long-context usage at flat rates', async () => {
const store = createStoreWithState({
dailyAggregates: [
{
day: '2026-04-09',
model: 'claude-sonnet-5',
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
cacheWriteTokens: 300_000
}
]
})
const summary = await store.getSummary('orca', '30d')
// Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not.
expect(summary.estimatedCostUsd).toBeCloseTo(6.615)
})
it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => {
const store = createStoreWithState({
dailyAggregates: ['claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'].map((model) => ({
day: '2026-04-09',
model,
projectKey: 'worktree:repo-1::/workspace/repo-a',
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo-a',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 300_000,
outputTokens: 300_000,
cacheReadTokens: 300_000,
cacheWriteTokens: 300_000
}))
})
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.
expect(
breakdown.find((row) => row.key === 'claude-sonnet-4-5-20250929')?.estimatedCostUsd
).toBeCloseTo(8.07)
// 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(
breakdown.find((row) => row.key === 'claude-opus-4-5-20251101')?.estimatedCostUsd
).toBeCloseTo(11.025)
})
it('prices unknown newer Opus 4 point releases with current Opus rates', async () => {
const store = createStoreWithState({
dailyAggregates: [

View File

@ -59,6 +59,11 @@ const SONNET_LONG_CONTEXT_PRICING = {
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
// Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
@ -156,6 +161,12 @@ function normalizeModelForPricing(model: string | null): string | null {
if (alias) {
return alias
}
if (hasClaudeModelVersion(lower, 'fable', '5')) {
return 'claude-fable-5'
}
if (hasClaudeModelVersion(lower, 'opus', '5')) {
return 'claude-opus-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-8')) {
return 'claude-opus-4-8'
}
@ -179,6 +190,9 @@ function normalizeModelForPricing(model: string | null): string | null {
// avoid overbilling unknown future Claude Code model IDs as legacy Opus 4.
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'sonnet', '5')) {
return 'claude-sonnet-5'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) {
return 'claude-sonnet-4-6'
}

View File

@ -364,6 +364,92 @@ describe('CodexUsageStore', () => {
expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(50.424)
})
it('prices GPT-5.6 sol, terra, and luna with current OpenAI rates', async () => {
const store = createStoreWithState({
dailyAggregates: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'].map((model) => ({
day: '2026-04-09',
model,
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 2_000_000,
cachedInputTokens: 1_000_000,
outputTokens: 1_000_000,
reasoningOutputTokens: 100_000,
totalTokens: 3_000_000,
hasInferredPricing: false
}))
})
const summary = await store.getSummary('orca', '30d')
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(summary.estimatedCostUsd).toBeCloseTo(85.7208)
expect(breakdown.find((row) => row.key === 'gpt-5.6-sol')?.estimatedCostUsd).toBeCloseTo(50.424)
expect(breakdown.find((row) => row.key === 'gpt-5.6-terra')?.estimatedCostUsd).toBeCloseTo(
25.212
)
expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(
10.0848
)
})
it('normalizes GPT-5.6 reasoning suffixes before pricing', async () => {
const store = createStoreWithState({
dailyAggregates: ['gpt-5.6-terra-high', 'gpt-5.6-luna(medium)'].map((model) => ({
day: '2026-04-09',
model,
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 100_000,
cachedInputTokens: 50_000,
outputTokens: 25_000,
reasoningOutputTokens: 5_000,
totalTokens: 125_000,
hasInferredPricing: false
}))
})
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(breakdown.find((row) => row.key === 'gpt-5.6-terra-high')?.estimatedCostUsd).toBeCloseTo(
0.5125
)
expect(
breakdown.find((row) => row.key === 'gpt-5.6-luna(medium)')?.estimatedCostUsd
).toBeCloseTo(0.205)
})
it('prices the bare gpt-5.6 alias at Sol rates without shadowing the tier IDs', async () => {
const store = createStoreWithState({
dailyAggregates: ['gpt-5.6', 'gpt-5.6-luna'].map((model) => ({
day: '2026-04-09',
model,
projectKey: 'worktree:repo-1::/workspace/repo',
projectLabel: 'Repo',
repoId: 'repo-1',
worktreeId: 'repo-1::/workspace/repo',
eventCount: 1,
inputTokens: 100_000,
cachedInputTokens: 50_000,
outputTokens: 25_000,
reasoningOutputTokens: 5_000,
totalTokens: 125_000,
hasInferredPricing: false
}))
})
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(breakdown.find((row) => row.key === 'gpt-5.6')?.estimatedCostUsd).toBeCloseTo(1.025)
expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(0.205)
})
it('normalizes Codex model variants and reasoning suffixes before pricing', async () => {
const store = createStoreWithState({
dailyAggregates: [

View File

@ -90,6 +90,30 @@ const MODEL_PRICING: Record<string, CodexModelPricing> = {
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-sol': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-terra': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.6-luna': {
input: 1,
cachedInput: 0.1,
output: 6,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
}
}
@ -228,6 +252,21 @@ function normalizeModelForPricing(model: string | null): string | null {
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
return 'gpt-5.5'
}
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
return 'gpt-5.6-sol'
}
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
return 'gpt-5.6-terra'
}
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
return 'gpt-5.6-luna'
}
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
// cheaper variant.
if (normalized === 'gpt-5.6') {
return 'gpt-5.6-sol'
}
return null
}