diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 5adc0508..80d7bfb3 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -17,13 +17,23 @@ import { // TYPE DEFINITIONS // ============================================================================ -export interface ModelPricing { +export interface PricingRates { inputPerMillion: number; outputPerMillion: number; cacheCreationPerMillion: number; cacheReadPerMillion: number; } +export interface ModelPricing extends PricingRates { + /** + * Optional per-service-tier rate overrides keyed by Anthropic's + * `service_tier` request parameter (e.g. `'fast'`). When the lookup is + * called with a known tier, these rates replace the base ones; otherwise + * the base rates apply. + */ + serviceTiers?: Record; +} + export interface TokenUsage { inputTokens: number; outputTokens: number; @@ -31,7 +41,14 @@ export interface TokenUsage { cacheReadTokens: number; } -export type PricingLookupOptions = ModelsDevPricingLookupOptions; +export interface PricingLookupOptions extends ModelsDevPricingLookupOptions { + /** + * Anthropic `service_tier` (e.g. `'fast'`). When set and the resolved model + * has matching `serviceTiers` rates, those are returned instead of the + * base rates. Unknown tiers transparently fall through to base. + */ + serviceTier?: string; +} // ============================================================================ // USER-EDITABLE PRICING TABLE @@ -223,12 +240,20 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, - // Claude 4.6 Opus ($5/$25) + // Claude 4.6 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-6': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 30.0, + outputPerMillion: 150.0, + cacheCreationPerMillion: 37.5, + cacheReadPerMillion: 3.0, + }, + }, }, 'claude-opus-4-6-thinking': { inputPerMillion: 5.0, @@ -236,19 +261,35 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, - // Claude 4.7 Opus ($5/$25) + // Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-7': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 30.0, + outputPerMillion: 150.0, + cacheCreationPerMillion: 37.5, // 30 * 1.25 (5m cache write multiplier) + cacheReadPerMillion: 3.0, // 30 * 0.1 (cache read multiplier) + }, + }, }, - // Claude 4.8 Opus ($5/$25) + // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) 'claude-opus-4-8': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, // 10 * 1.25 (5m cache write multiplier) + cacheReadPerMillion: 1.0, // 10 * 0.1 (cache read multiplier) + }, + }, }, // --------------------------------------------------------------------------- @@ -893,12 +934,28 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole return Boolean(options.provider || /^[^/]+\//.test(model.trim())); } +/** + * Apply per-service-tier rates if the resolved model declares them and the + * caller requested a matching tier. Unknown tiers transparently fall through + * to the base rates so existing callers stay unaffected. + */ +function applyServiceTier(pricing: ModelPricing, tier: string | undefined): ModelPricing { + if (!tier) return pricing; + const tierRates = pricing.serviceTiers?.[tier]; + if (!tierRates) return pricing; + return { ...tierRates, serviceTiers: pricing.serviceTiers }; +} + /** * Get pricing for a model with narrow fuzzy matching fallback. * Unknown future model families should fall back instead of inheriting the * first known family tier that happens to share a prefix. */ export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { + return applyServiceTier(resolveBasePricing(model, options), options.serviceTier); +} + +function resolveBasePricing(model: string, options: PricingLookupOptions): ModelPricing { if (hasProviderContext(model, options)) { const ccsOverridePricing = getCcsPolicyOverridePricing(model); if (ccsOverridePricing !== undefined) { diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 540afd3f..e5578831 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -176,6 +176,46 @@ describe('model-pricing', () => { expect(opus48.cacheReadPerMillion).toBe(0.5); }); + it('should match date-stamped Claude Opus 4.8 to correct pricing', () => { + // Anthropic stopped issuing date-stamped Opus IDs starting with the 4.6 + // generation, so this is a defensive guard for stripDateSuffix in case + // a third-party emits a synthesized dated alias. + const opus48dated = getModelPricing('claude-opus-4-8-20260530'); + expect(opus48dated.inputPerMillion).toBe(5.0); + expect(opus48dated.outputPerMillion).toBe(25.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.8 when serviceTier=fast', () => { + // Anthropic's "fast mode" charges 2x for Opus 4.8 ($10/$50 vs $5/$25). + // Cache rates scale by the same standard multipliers (1.25x write, 0.1x read). + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.inputPerMillion).toBe(10.0); + expect(opus48fast.outputPerMillion).toBe(50.0); + expect(opus48fast.cacheCreationPerMillion).toBe(12.5); + expect(opus48fast.cacheReadPerMillion).toBe(1.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.7 when serviceTier=fast', () => { + // Fast mode on 4.7 carries a 6x premium ($30/$150 per Anthropic docs). + const opus47fast = getModelPricing('claude-opus-4-7', { serviceTier: 'fast' }); + expect(opus47fast.inputPerMillion).toBe(30.0); + expect(opus47fast.outputPerMillion).toBe(150.0); + }); + + it('should fall back to standard rates when serviceTier is unknown', () => { + // Unknown tier names must not throw; revert to base pricing transparently. + const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' }); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + + it('should preserve standard rates for Opus 4.8 when serviceTier omitted', () => { + // Regression guard: existing callers must keep current behavior. + const opus48 = getModelPricing('claude-opus-4-8'); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -285,6 +325,17 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'claude-opus-4-8'); expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); + + it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { + 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-8', { serviceTier: 'fast' }); + expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0 + }); }); describe('getKnownModels', () => {