From cf513adddcf0fa3c4ca98704b559b5f09cc554ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:06:23 -0700 Subject: [PATCH] feat(usage): price Claude 5 family and GPT-5.6 token usage (#10822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/main/claude-usage/store.test.ts | 119 ++++++++++++++++++++++++++++ src/main/claude-usage/store.ts | 14 ++++ src/main/codex-usage/store.test.ts | 86 ++++++++++++++++++++ src/main/codex-usage/store.ts | 39 +++++++++ 4 files changed, 258 insertions(+) diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 08a76e469..85d85d8ba 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -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: [ diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index a020186ee..549758fc6 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -59,6 +59,11 @@ const SONNET_LONG_CONTEXT_PRICING = { } satisfies Partial const MODEL_PRICING: Record = { + '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' } diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 055af8f3a..a20472abd 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -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: [ diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index ca42ba340..f3c62fb89 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -90,6 +90,30 @@ const MODEL_PRICING: Record = { 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 }