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', () => {