From 357ce8b07ad1eb12879f24d4a4b0eeb90d66bbd4 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 30 May 2026 17:16:27 +0200 Subject: [PATCH 1/7] feat(catalog): add Claude Opus 4.8 to Anthropic model registry Register `claude-opus-4-8` in the cliproxy model catalog, web-server pricing table, and UI catalog as an additive entry next to 4.7. Defaults are unchanged (claude provider stays on sonnet-4-6); users opt into 4.8 explicitly via ANTHROPIC_MODEL or the dashboard. Configuration mirrors 4.7: - adaptive thinking levels (low through max), max distinct from xhigh - extendedContext (1M) available - nativeImageInput - pricing $5/$25 in/out, $6.25/$0.5 cache Built [OnSteroids](https://onsteroids.ai) --- src/cliproxy/__tests__/model-catalog.test.js | 13 +++++++ .../__tests__/thinking-validator.test.ts | 9 +++++ src/cliproxy/model-catalog.ts | 19 +++++++++- src/web-server/model-pricing.ts | 7 ++++ tests/unit/model-pricing.test.ts | 37 ++++++++++++++----- ui/src/lib/model-catalogs.ts | 16 +++++++- ui/tests/unit/ui/lib/preset-utils.test.ts | 8 ++-- 7 files changed, 92 insertions(+), 17 deletions(-) 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 29f6052e..aa8dc0bc 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -307,10 +307,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. @@ -327,7 +342,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 6d7a8bfa..5adc0508 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -243,6 +243,13 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, + // Claude 4.8 Opus ($5/$25) + 'claude-opus-4-8': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 9e6eca84..540afd3f 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -168,6 +168,14 @@ 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 not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -266,6 +274,17 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'claude-opus-4-7'); 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 + }); }); describe('getKnownModels', () => { @@ -469,17 +488,15 @@ describe('model-pricing', () => { }); it('gracefully ignores malformed cached model entries', () => { - setCachedModelsDevRegistry( - { - openai: { - id: 'openai', - models: { - 'null-entry': null, - 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, - }, + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, }, - } as unknown as Parameters[0] - ); + }, + } as unknown as Parameters[0]); expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow(); expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index df7d35dd..af8382c1 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -630,10 +630,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', @@ -645,7 +657,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/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index ecc472ff..4edeb2cd 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 () => { From 878775f5b63e0f5debc563abd2d5d888a5488b80 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 30 May 2026 22:17:21 +0200 Subject: [PATCH 2/7] feat(pricing): support per-service-tier rates; wire Opus fast mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `ModelPricing` with an optional `serviceTiers` map keyed by Anthropic's `service_tier` request parameter. `getModelPricing(model, { serviceTier })` returns the matching tier rates when present and transparently falls through to base rates otherwise — existing call sites keep current behavior. Registry entries added (per Anthropic Fast mode pricing docs): - claude-opus-4-8 fast: $10/$50 input/output (2x premium) - claude-opus-4-7 fast: $30/$150 (6x premium) - claude-opus-4-6 fast: $30/$150 (6x premium) Cache rates derived from the documented Prompt caching multipliers (1.25x for 5-min cache write, 0.1x for cache read). Note: tier tracking through CliproxyRequestDetail is not wired yet — that's a follow-up so usage transformers can pass `serviceTier` when Anthropic returns it on the response. Schema is in place; integration can land independently. Also adds a defensive `claude-opus-4-8-20260530` pricing test to exercise `stripDateSuffix` even though Anthropic no longer issues date-stamped IDs for the 4.6+ generation (addresses presto-review suggestion #1). Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 67 +++++++++++++++++++++++++++++--- tests/unit/model-pricing.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) 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', () => { From 5157a922a5ed0461381741d8a7b624c2dcd6f71d Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 30 May 2026 23:01:58 +0200 Subject: [PATCH 3/7] test(accounts): align stale free-tier expectations with current behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 72ea1fc9 ("fix(accounts): simplify codex free tier badges") split free codex accounts out of the `personal` audience into a distinct `free` audience and dropped the `'Personal · '` prefix on inlineLabel, but `account-visual-groups.test.ts` was not updated and has been failing in CI ever since. Update expectations to match the shipped behavior: - `audience` for `-free` tokens is `'free'`, not `'personal'` - `inlineLabel` for a bare free account is `'Free'`, not `'Personal · Free'` - `compactDetailLabel` is `null` when no extra parts are present - Sort order: business -> personal -> free -> unknown Drive-by fix unrelated to the Opus 4.8 work in this PR but caught because these tests started failing on every push to the branch. Built [OnSteroids](https://onsteroids.ai) --- ui/tests/unit/ui/lib/account-visual-groups.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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', ]); }); }); From 254fc88f717dd5f649aa6ffa30b1939ac3f9078f Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 10:34:50 +0200 Subject: [PATCH 4/7] refactor(pricing): derive fast-tier cache rates via buildRates helper Address review feedback on the per-service-tier pricing: - Extract `buildRates(input, output)` plus named CACHE_5M_WRITE_MULTIPLIER / CACHE_READ_MULTIPLIER constants so fast-tier cache rates are derived from Anthropic's documented multipliers instead of hand-computed numbers. This removes the inconsistency where Opus 4.6 fast-tier lacked the explanatory inline comments that 4.7/4.8 carried (presto suggestion #3). - Document in `applyServiceTier` that no production caller passes `serviceTier` yet (Anthropic's service_tier is not captured on CliproxyRequestDetail), so fast-mode usage is currently billed at the standard rate until the usage pipeline records the tier. - Add a combined date-suffix + fast-tier lookup test to prove the two resolution stages compose. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 48 ++++++++++++++++++++------------ tests/unit/model-pricing.test.ts | 10 +++++++ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index a37e084f..ab423c07 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -50,6 +50,26 @@ export interface PricingLookupOptions extends ModelsDevPricingLookupOptions { 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, + }; +} + // ============================================================================ // USER-EDITABLE PRICING TABLE // Update rates below (per million tokens in USD) @@ -247,12 +267,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: { - inputPerMillion: 30.0, - outputPerMillion: 150.0, - cacheCreationPerMillion: 37.5, - cacheReadPerMillion: 3.0, - }, + fast: buildRates(30.0, 150.0), }, }, 'claude-opus-4-6-thinking': { @@ -268,12 +283,7 @@ const PRICING_REGISTRY: Record = { 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) - }, + fast: buildRates(30.0, 150.0), }, }, // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) @@ -283,12 +293,7 @@ const PRICING_REGISTRY: Record = { 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) - }, + fast: buildRates(10.0, 50.0), }, }, 'claude-opus-4-7-thinking': { @@ -944,6 +949,13 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole * 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; diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 59eb0523..653b5664 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -202,6 +202,16 @@ describe('model-pricing', () => { expect(opus47fast.outputPerMillion).toBe(150.0); }); + 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 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' }); From e8057a70734e1d7556dbc53aec1739c73d0cf8cf Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 18:16:23 +0200 Subject: [PATCH 5/7] refactor(pricing): share Opus fast-tier rate constants Hoist the fast-mode rate sets into OPUS_46_47_FAST_RATES and OPUS_48_FAST_RATES module constants (built via buildRates) so the registry entries reference shared values instead of repeating literal buildRates(...) calls. Also wires the fast tier onto the -thinking aliases (claude-opus-4-6-thinking / -4-7-thinking) so they bill at the same premium as their base ids. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index ab423c07..c8afc8e2 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -70,6 +70,13 @@ function buildRates(inputPerMillion: number, outputPerMillion: number): PricingR }; } +// 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 // Update rates below (per million tokens in USD) @@ -267,7 +274,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(30.0, 150.0), + fast: OPUS_46_47_FAST_RATES, }, }, 'claude-opus-4-6-thinking': { @@ -275,6 +282,9 @@ const PRICING_REGISTRY: Record = { outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, // Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-7': { @@ -283,7 +293,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(30.0, 150.0), + fast: OPUS_46_47_FAST_RATES, }, }, // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) @@ -293,7 +303,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(10.0, 50.0), + fast: OPUS_48_FAST_RATES, }, }, 'claude-opus-4-7-thinking': { @@ -301,6 +311,9 @@ const PRICING_REGISTRY: Record = { outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, // --------------------------------------------------------------------------- From 5a79b475063a3451b14e8805f9b0dc05397930cb Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 18:52:54 +0200 Subject: [PATCH 6/7] fix(pricing): restore opus version order; cover 4.6/4.7 fast-tier rates Address presto review on the fast-tier work: - Move the claude-opus-4-8 entry after claude-opus-4-7-thinking so the registry keeps chronological 4.6 -> 4.7 -> 4.8 ordering (the 4.8 insert had split the 4-7 / 4-7-thinking pair). - Drop the fast serviceTier from claude-opus-4-7-thinking: that id is a legacy pricing-only entry with no catalog model, so a fast premium on it is meaningless. The active 4.6-thinking variant (agy provider) keeps its fast tier. - Extend the 4.7 fast-tier test with cache-rate assertions and add an equivalent 4.6 fast-tier test so the derived buildRates math is covered for every premium-tier model. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 15 ++++++--------- tests/unit/model-pricing.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index c8afc8e2..25127406 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -296,6 +296,12 @@ const PRICING_REGISTRY: Record = { fast: OPUS_46_47_FAST_RATES, }, }, + '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, @@ -306,15 +312,6 @@ const PRICING_REGISTRY: Record = { fast: OPUS_48_FAST_RATES, }, }, - 'claude-opus-4-7-thinking': { - inputPerMillion: 5.0, - outputPerMillion: 25.0, - cacheCreationPerMillion: 6.25, - cacheReadPerMillion: 0.5, - serviceTiers: { - fast: OPUS_46_47_FAST_RATES, - }, - }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 653b5664..95b6c7f1 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -200,6 +200,17 @@ describe('model-pricing', () => { 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', () => { From 1fc595f10cda9c13fa0afd38c98c9fec1b869ae5 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 20:23:14 +0200 Subject: [PATCH 7/7] docs(pricing): document legacy 4-7-thinking entry; cover tier metadata Final review polish: - Add an inline comment on the claude-opus-4-7-thinking registry entry explaining it is a pricing-only id kept for historical analytics data (no catalog model after Opus 4.7 moved to adaptive thinking levels), and why it carries no fast tier. - Add a test asserting applyServiceTier preserves the serviceTiers map on its result, guarding against a future simplification dropping it. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 4 ++++ tests/unit/model-pricing.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 25127406..7c01c32e 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -296,6 +296,10 @@ const PRICING_REGISTRY: Record = { 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, diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 95b6c7f1..16652330 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -223,6 +223,14 @@ describe('model-pricing', () => { 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' });