From 9a7ca3f1c0f6e5518d8f130d9d061599d3c27ad0 Mon Sep 17 00:00:00 2001 From: theodep Date: Tue, 3 Mar 2026 16:24:43 +0100 Subject: [PATCH 1/3] feat(pricing): fix claude sonnet 4.6 and claude opus 4.6 pricing --- src/web-server/model-pricing.ts | 53 ++++++++++++++++++++++++++++-- src/web-server/usage/disk-cache.ts | 3 +- tests/unit/model-pricing.test.ts | 36 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 4513d36c..dc05d2a6 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -151,6 +151,19 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, + // Claude 4.6 Sonnet ($3/$15) + 'claude-sonnet-4-6': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4-6-thinking': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { inputPerMillion: 15.0, @@ -202,6 +215,19 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, + // Claude 4.6 Opus ($5/$25) + 'claude-opus-4-6': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + 'claude-opus-4-6-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage @@ -708,7 +734,7 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { /** * Normalize model name for matching - * Handles variations like provider prefixes and case differences + * Handles variations like provider prefixes, case differences, and date suffixes */ function normalizeModelName(model: string): string { // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") @@ -719,6 +745,14 @@ function normalizeModelName(model: string): string { return normalized; } +/** + * Strip trailing date suffix from model name (e.g., "-20260101") + * Only strips 8-digit YYYYMMDD suffixes at the end of the string. + */ +function stripDateSuffix(model: string): string { + return model.replace(/-\d{8}$/, ''); +} + const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( PRICING_REGISTRY ).reduce>((acc, [key, pricing]) => { @@ -730,7 +764,22 @@ function getLookupCandidates(model: string): string[] { const normalized = normalizeModelName(model); const baseModel = normalized.split(':')[0]; - return baseModel === normalized ? [normalized] : [normalized, baseModel]; + const candidates: string[] = [normalized]; + if (baseModel !== normalized) { + candidates.push(baseModel); + } + + // Add date-stripped variants (e.g., "claude-opus-4-6-20260101" -> "claude-opus-4-6") + const stripped = stripDateSuffix(normalized); + if (stripped !== normalized && !candidates.includes(stripped)) { + candidates.push(stripped); + } + const baseStripped = stripDateSuffix(baseModel); + if (baseStripped !== baseModel && !candidates.includes(baseStripped)) { + candidates.push(baseStripped); + } + + return candidates; } function getDirectOrAliasPricing(model: string): ModelPricing | undefined { diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index 523169c9..c97d1632 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -36,7 +36,8 @@ export interface UsageDiskCache { // Current cache version - increment to invalidate old caches // v3: Added hourly data to cache -const CACHE_VERSION = 3; +// v4: Pricing fix for Claude 4.6 models (invalidate stale costs) +const CACHE_VERSION = 4; /** * Ensure ~/.ccs/cache directory exists diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 30efdc59..0317b995 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -84,6 +84,42 @@ describe('model-pricing', () => { expect(opus.inputPerMillion).toBeGreaterThan(sonnet.inputPerMillion); expect(sonnet.inputPerMillion).toBeGreaterThan(haiku.inputPerMillion); }); + + it('should return correct pricing for Claude Opus 4.6 (not 3x Opus 4 rate)', () => { + const opus46 = getModelPricing('claude-opus-4-6'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); + + it('should return correct pricing for Claude Opus 4.6 thinking variant', () => { + const opus46t = getModelPricing('claude-opus-4-6-thinking'); + expect(opus46t.inputPerMillion).toBe(5.0); + expect(opus46t.outputPerMillion).toBe(25.0); + }); + + it('should return correct pricing for Claude Sonnet 4.6', () => { + const sonnet46 = getModelPricing('claude-sonnet-4-6'); + expect(sonnet46.inputPerMillion).toBe(3.0); + expect(sonnet46.outputPerMillion).toBe(15.0); + }); + + it('should match date-stamped Claude Opus 4.6 to correct pricing', () => { + const opus46dated = getModelPricing('claude-opus-4-6-20260101'); + expect(opus46dated.inputPerMillion).toBe(5.0); + expect(opus46dated.outputPerMillion).toBe(25.0); + }); + + it('should match date-stamped Claude Sonnet 4.6 to correct pricing', () => { + const sonnet46dated = getModelPricing('claude-sonnet-4-6-20260115'); + expect(sonnet46dated.inputPerMillion).toBe(3.0); + expect(sonnet46dated.outputPerMillion).toBe(15.0); + }); + + it('should match provider-prefixed date-stamped model to correct pricing', () => { + const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); }); describe('calculateCost', () => { From d31f85b0d4ccbf0edddcd2f58a49c1f9777cc6e1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 23:08:04 +0700 Subject: [PATCH 2/3] fix(pricing): handle date-stamped 4.6 thinking model IDs --- src/web-server/model-pricing.ts | 10 +++++++--- tests/unit/model-pricing.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index dc05d2a6..935ecccf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -734,7 +734,7 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { /** * Normalize model name for matching - * Handles variations like provider prefixes, case differences, and date suffixes + * Handles variations like provider prefixes and case differences */ function normalizeModelName(model: string): string { // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") @@ -747,10 +747,14 @@ function normalizeModelName(model: string): string { /** * Strip trailing date suffix from model name (e.g., "-20260101") - * Only strips 8-digit YYYYMMDD suffixes at the end of the string. + * Claude session IDs can place dates either at the end or before "-thinking". */ function stripDateSuffix(model: string): string { - return model.replace(/-\d{8}$/, ''); + if (!model.startsWith('claude-')) { + return model; + } + + return model.replace(/-\d{8}(?=-thinking(?:$|:))/g, '').replace(/-\d{8}(?=$|:)/g, ''); } const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 0317b995..ecbbdd42 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -120,6 +120,18 @@ describe('model-pricing', () => { expect(opus46.inputPerMillion).toBe(5.0); expect(opus46.outputPerMillion).toBe(25.0); }); + + it('should match date-stamped thinking Claude Opus 4.6 to correct pricing', () => { + const opus46 = getModelPricing('claude-opus-4-6-20260101-thinking'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); + + it('should match provider-prefixed date-stamped thinking model to correct pricing', () => { + const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101-thinking'); + expect(opus46.inputPerMillion).toBe(5.0); + expect(opus46.outputPerMillion).toBe(25.0); + }); }); describe('calculateCost', () => { @@ -209,6 +221,10 @@ describe('model-pricing', () => { expect(hasCustomPricing('qwen3-coder')).toBe(true); }); + it('should not treat date-stamped non-Claude IDs as deterministic aliases', () => { + expect(hasCustomPricing('qwen3-32b-20260101')).toBe(false); + }); + it('should return false for unknown models', () => { expect(hasCustomPricing('unknown-model-xyz')).toBe(false); }); From 91050bfebf15fa39eefdc819af3cd0450c79889e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 23:09:51 +0700 Subject: [PATCH 3/3] test(pricing): cover Claude 4.6 cache token cost rates --- tests/unit/model-pricing.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index ecbbdd42..3b483563 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -191,6 +191,17 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'gemini-2.0-flash-exp'); expect(cost).toBe(0); // Experimental models are free }); + + it('should calculate Claude Opus 4.6 cost including cache token rates', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }; + const cost = calculateCost(usage, 'claude-opus-4-6'); + expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 + }); }); describe('getKnownModels', () => {