diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index bc11ce90..bb3c3bdd 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -155,6 +155,19 @@ describe('Model Catalog', () => { assert.strictEqual(opus47.extendedContext, true); }); + it('includes Claude Opus 4.8 with adaptive levels and extended context', () => { + const { MODEL_CATALOG } = modelCatalog; + const opus48 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-8'); + assert(opus48, 'Should include Claude Opus 4.8'); + assert.strictEqual(opus48.name, 'Claude Opus 4.8'); + // Mirrors 4.7: Anthropic only accepts adaptive thinking levels on the + // current Opus generation; budget_tokens is rejected with 400. + assert.strictEqual(opus48.thinking.type, 'levels'); + assert.deepStrictEqual(opus48.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(opus48.thinking.maxLevel, 'max'); + assert.strictEqual(opus48.extendedContext, true); + }); + it('retains previous 4.5 snapshot models for explicit selection', () => { const { MODEL_CATALOG } = modelCatalog; const ids = MODEL_CATALOG.claude.models.map((m) => m.id); diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index c1c21ebf..221eca94 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -51,6 +51,15 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Opus 4.8', () => { + // Opus 4.8 inherits 4.7's adaptive thinking surface; max must remain + // distinct from xhigh. + const result = validateThinking('claude', 'claude-opus-4-8', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should still alias max -> xhigh for models without a max level (backcompat)', () => { // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. // User input "max" should map down to xhigh rather than be rejected. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index e4144394..2192107a 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -369,10 +369,25 @@ export const MODEL_CATALOG: Partial> = displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-opus-4-8', + name: 'Claude Opus 4.8', + description: 'Latest flagship model', + nativeImageInput: true, + // Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the + // current Opus generation; manual budget_tokens is rejected with 400. + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', - description: 'Latest flagship model', + description: 'Previous flagship model', nativeImageInput: true, // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual // thinking.type: "enabled" with budget_tokens is rejected with 400. @@ -389,7 +404,7 @@ export const MODEL_CATALOG: Partial> = { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', - description: 'Previous flagship model', + description: 'Older flagship model', nativeImageInput: true, thinking: { type: 'budget', diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index a8f42fe0..7c01c32e 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,41 @@ 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; +} + +// Anthropic prompt-caching multipliers, expressed relative to the base input +// rate (see https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching). +// Keeping them as named constants avoids drift when deriving per-tier cache rates. +const CACHE_5M_WRITE_MULTIPLIER = 1.25; +const CACHE_READ_MULTIPLIER = 0.1; + +/** + * Build a full rate set from input/output rates, deriving cache rates from the + * documented Anthropic multipliers so fast-tier entries stay in sync with the + * base-rate math instead of carrying hand-computed cache numbers. + */ +function buildRates(inputPerMillion: number, outputPerMillion: number): PricingRates { + return { + inputPerMillion, + outputPerMillion, + cacheCreationPerMillion: inputPerMillion * CACHE_5M_WRITE_MULTIPLIER, + cacheReadPerMillion: inputPerMillion * CACHE_READ_MULTIPLIER, + }; +} + +// Anthropic fast-mode premiums (per +// https://platform.claude.com/docs/en/about-claude/pricing#fast-mode-pricing). +// Opus 4.6 and 4.7 share the same 6x premium; 4.8 is 2x. Shared constants keep +// the registry entries in sync rather than repeating the literal rates. +const OPUS_46_47_FAST_RATES = buildRates(30.0, 150.0); +const OPUS_48_FAST_RATES = buildRates(10.0, 50.0); // ============================================================================ // USER-EDITABLE PRICING TABLE @@ -223,32 +267,55 @@ 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: OPUS_46_47_FAST_RATES, + }, }, 'claude-opus-4-6-thinking': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, - // 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: OPUS_46_47_FAST_RATES, + }, }, + // Legacy pricing-only entry for historical analytics data; this id has no + // catalog model (Opus 4.7 moved to adaptive thinking levels in 84dc4e24, so + // there is no separate -thinking variant). Kept so older usage records still + // resolve to the correct rate. No fast tier: the id is never requested live. 'claude-opus-4-7-thinking': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, + // 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: OPUS_48_FAST_RATES, + }, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage @@ -892,12 +959,35 @@ 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. + * + * TODO(opus-fast-mode): No production caller currently passes `serviceTier`. + * Anthropic's `service_tier` is not yet captured on CliproxyRequestDetail, so + * usage transformers (cliproxy-usage-transformer.ts, data-aggregator.ts) bill + * fast-mode requests at the standard rate — i.e. fast Opus is under-reported by + * the tier premium ($10/$50 vs $5/$25). Wire `serviceTier` through once the + * usage pipeline records it. The schema below is ready for that integration. + */ +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 a6951211..16652330 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -168,6 +168,83 @@ describe('model-pricing', () => { expect(opus47dated.outputPerMillion).toBe(25.0); }); + it('should return correct pricing for Claude Opus 4.8', () => { + const opus48 = getModelPricing('claude-opus-4-8'); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + expect(opus48.cacheCreationPerMillion).toBe(6.25); + 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); + expect(opus47fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus47fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 + }); + + it('should return fast-tier pricing for Claude Opus 4.6 when serviceTier=fast', () => { + // Fast mode on 4.6 carries the same 6x premium as 4.7 ($30/$150). + const opus46fast = getModelPricing('claude-opus-4-6', { serviceTier: 'fast' }); + expect(opus46fast.inputPerMillion).toBe(30.0); + expect(opus46fast.outputPerMillion).toBe(150.0); + expect(opus46fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus46fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 + }); + + it('should apply fast-tier pricing together with date-suffix stripping', () => { + // stripDateSuffix (base lookup) and applyServiceTier run independently; + // confirm they compose for a date-stamped id requesting the fast tier. + const opus48fastDated = getModelPricing('claude-opus-4-8-20260530', { serviceTier: 'fast' }); + expect(opus48fastDated.inputPerMillion).toBe(10.0); + expect(opus48fastDated.outputPerMillion).toBe(50.0); + expect(opus48fastDated.cacheCreationPerMillion).toBe(12.5); + expect(opus48fastDated.cacheReadPerMillion).toBe(1.0); + }); + + it('should preserve serviceTiers metadata when applying tier rates', () => { + // applyServiceTier must keep the serviceTiers map on the returned object + // so callers can still inspect available tiers after rate substitution. + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.serviceTiers).toBeDefined(); + expect(opus48fast.serviceTiers?.fast).toBeDefined(); + }); + + 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'); @@ -266,6 +343,28 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'claude-opus-4-7-thinking'); expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); + + it('should calculate Claude Opus 4.8 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-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', () => { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9edde56c..8059da26 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -752,10 +752,22 @@ export const MODEL_CATALOGS: Record = { displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-opus-4-8', + name: 'Claude Opus 4.8', + description: 'Latest flagship model', + extendedContext: true, + presetMapping: { + default: 'claude-opus-4-8', + opus: 'claude-opus-4-8', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', - description: 'Latest flagship model', + description: 'Previous flagship model', extendedContext: true, presetMapping: { default: 'claude-opus-4-7', @@ -767,7 +779,7 @@ export const MODEL_CATALOGS: Record = { { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', - description: 'Previous flagship model', + description: 'Older flagship model', extendedContext: true, presetMapping: { default: 'claude-opus-4-6', diff --git a/ui/tests/unit/ui/lib/account-visual-groups.test.ts b/ui/tests/unit/ui/lib/account-visual-groups.test.ts index cba2b9c2..eae7c8da 100644 --- a/ui/tests/unit/ui/lib/account-visual-groups.test.ts +++ b/ui/tests/unit/ui/lib/account-visual-groups.test.ts @@ -29,17 +29,14 @@ describe('buildAccountVisualGroups', () => { ]); expect(groups).toHaveLength(1); - expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([ - 'business', - 'personal', - ]); + expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual(['business', 'free']); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ 'Business · Workspace 04a0f049', - 'Personal · Free', + 'Free', ]); expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([ '04a0f049', - 'Free', + null, ]); expect(groups[0]?.memberIds).toEqual([ 'kaidu.kd@gmail.com#04a0f049-team', @@ -47,7 +44,7 @@ describe('buildAccountVisualGroups', () => { ]); }); - it('keeps multiple personal codex plans distinct inside the same grouped card', () => { + it('keeps personal and free codex plans distinct inside the same grouped card', () => { const groups = buildAccountVisualGroups([ makeAccount({ id: 'kaidu.kd@gmail.com#plus', @@ -61,8 +58,8 @@ describe('buildAccountVisualGroups', () => { expect(groups).toHaveLength(1); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ - 'Personal · Free', 'Personal · Plus', + 'Free', ]); }); }); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 4199e8b9..d77a7a4d 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -16,12 +16,14 @@ describe('claude preset utils', () => { vi.restoreAllMocks(); }); - it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7', () => { + it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7 and 4.8', () => { const claudeCatalog = MODEL_CATALOGS.claude; + const ids = claudeCatalog.models.map((model) => model.id); expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); - expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-opus-4-7'); - expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-sonnet-4-6'); + expect(ids).toContain('claude-opus-4-8'); + expect(ids).toContain('claude-opus-4-7'); + expect(ids).toContain('claude-sonnet-4-6'); }); it('applies the default claude preset from the catalog default model mapping', async () => {