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:
parent
05603a2e78
commit
cf513adddc
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue