diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 4513d36c..935ecccf 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 @@ -719,6 +745,18 @@ function normalizeModelName(model: string): string { return normalized; } +/** + * Strip trailing date suffix from model name (e.g., "-20260101") + * Claude session IDs can place dates either at the end or before "-thinking". + */ +function stripDateSuffix(model: string): string { + if (!model.startsWith('claude-')) { + return model; + } + + return model.replace(/-\d{8}(?=-thinking(?:$|:))/g, '').replace(/-\d{8}(?=$|:)/g, ''); +} + const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( PRICING_REGISTRY ).reduce>((acc, [key, pricing]) => { @@ -730,7 +768,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..3b483563 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -84,6 +84,54 @@ 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); + }); + + 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', () => { @@ -143,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', () => { @@ -173,6 +232,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); });