Merge pull request #1457 from sgaluza/feat/claude-opus-4-8

feat(catalog): add Claude Opus 4.8 to Anthropic model registry
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-02 10:46:54 -04:00
committed by GitHub
8 changed files with 256 additions and 19 deletions
@@ -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);
@@ -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.
+17 -2
View File
@@ -369,10 +369,25 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
description: 'Previous flagship model',
description: 'Older flagship model',
nativeImageInput: true,
thinking: {
type: 'budget',
+94 -4
View File
@@ -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<string, PricingRates>;
}
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<string, ModelPricing> = {
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) {
+99
View File
@@ -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', () => {
+14 -2
View File
@@ -752,10 +752,22 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
{
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',
@@ -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',
]);
});
});
+5 -3
View File
@@ -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 () => {