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 authored and GitHub committed 2026-06-02 10:46:54 -04:00
commit cfb4d7bf19
8 files changed
+256 -19

No files matched your search

@@ -155,6 +155,19 @@ describe('Model Catalog', () => {
assert.strictEqual(opus47.extendedContext, true); 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', () => { it('retains previous 4.5 snapshot models for explicit selection', () => {
const { MODEL_CATALOG } = modelCatalog; const { MODEL_CATALOG } = modelCatalog;
const ids = MODEL_CATALOG.claude.models.map((m) => m.id); const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
@@ -51,6 +51,15 @@ describe('Thinking Validator', () => {
expect(result.warning).toBeUndefined(); 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)', () => { it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
// User input "max" should map down to xhigh rather than be rejected. // 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)', displayName: 'Claude (Anthropic)',
defaultModel: 'claude-sonnet-4-6', defaultModel: 'claude-sonnet-4-6',
models: [ 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', id: 'claude-opus-4-7',
name: 'Claude Opus 4.7', name: 'Claude Opus 4.7',
description: 'Latest flagship model', description: 'Previous flagship model',
nativeImageInput: true, nativeImageInput: true,
// Opus 4.7 only supports adaptive thinking on the Anthropic API; manual // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual
// thinking.type: "enabled" with budget_tokens is rejected with 400. // 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', id: 'claude-opus-4-6',
name: 'Claude Opus 4.6', name: 'Claude Opus 4.6',
description: 'Previous flagship model', description: 'Older flagship model',
nativeImageInput: true, nativeImageInput: true,
thinking: { thinking: {
type: 'budget', type: 'budget',
+94 -4
View File
@@ -17,13 +17,23 @@ import {
// TYPE DEFINITIONS // TYPE DEFINITIONS
// ============================================================================ // ============================================================================
export interface ModelPricing { export interface PricingRates {
inputPerMillion: number; inputPerMillion: number;
outputPerMillion: number; outputPerMillion: number;
cacheCreationPerMillion: number; cacheCreationPerMillion: number;
cacheReadPerMillion: 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 { export interface TokenUsage {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
@@ -31,7 +41,41 @@ export interface TokenUsage {
cacheReadTokens: number; 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 // USER-EDITABLE PRICING TABLE
@@ -223,32 +267,55 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
cacheCreationPerMillion: 6.25, cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5, 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': { 'claude-opus-4-6': {
inputPerMillion: 5.0, inputPerMillion: 5.0,
outputPerMillion: 25.0, outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25, cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5, cacheReadPerMillion: 0.5,
serviceTiers: {
fast: OPUS_46_47_FAST_RATES,
},
}, },
'claude-opus-4-6-thinking': { 'claude-opus-4-6-thinking': {
inputPerMillion: 5.0, inputPerMillion: 5.0,
outputPerMillion: 25.0, outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25, cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5, 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': { 'claude-opus-4-7': {
inputPerMillion: 5.0, inputPerMillion: 5.0,
outputPerMillion: 25.0, outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25, cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5, 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': { 'claude-opus-4-7-thinking': {
inputPerMillion: 5.0, inputPerMillion: 5.0,
outputPerMillion: 25.0, outputPerMillion: 25.0,
cacheCreationPerMillion: 6.25, cacheCreationPerMillion: 6.25,
cacheReadPerMillion: 0.5, 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 // OpenAI Models - Source: better-ccusage
@@ -892,12 +959,35 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole
return Boolean(options.provider || /^[^/]+\//.test(model.trim())); 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. * Get pricing for a model with narrow fuzzy matching fallback.
* Unknown future model families should fall back instead of inheriting the * Unknown future model families should fall back instead of inheriting the
* first known family tier that happens to share a prefix. * first known family tier that happens to share a prefix.
*/ */
export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { 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)) { if (hasProviderContext(model, options)) {
const ccsOverridePricing = getCcsPolicyOverridePricing(model); const ccsOverridePricing = getCcsPolicyOverridePricing(model);
if (ccsOverridePricing !== undefined) { if (ccsOverridePricing !== undefined) {
+99
View File
@@ -168,6 +168,83 @@ describe('model-pricing', () => {
expect(opus47dated.outputPerMillion).toBe(25.0); 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', () => { it('should not map unknown future model families onto known family pricing', () => {
const fallback = getModelPricing('unknown-model-xyz'); const fallback = getModelPricing('unknown-model-xyz');
@@ -266,6 +343,28 @@ describe('model-pricing', () => {
const cost = calculateCost(usage, 'claude-opus-4-7-thinking'); const cost = calculateCost(usage, 'claude-opus-4-7-thinking');
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 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', () => { describe('getKnownModels', () => {
+14 -2
View File
@@ -752,10 +752,22 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
displayName: 'Claude (Anthropic)', displayName: 'Claude (Anthropic)',
defaultModel: 'claude-sonnet-4-6', defaultModel: 'claude-sonnet-4-6',
models: [ 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', id: 'claude-opus-4-7',
name: 'Claude Opus 4.7', name: 'Claude Opus 4.7',
description: 'Latest flagship model', description: 'Previous flagship model',
extendedContext: true, extendedContext: true,
presetMapping: { presetMapping: {
default: 'claude-opus-4-7', default: 'claude-opus-4-7',
@@ -767,7 +779,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
{ {
id: 'claude-opus-4-6', id: 'claude-opus-4-6',
name: 'Claude Opus 4.6', name: 'Claude Opus 4.6',
description: 'Previous flagship model', description: 'Older flagship model',
extendedContext: true, extendedContext: true,
presetMapping: { presetMapping: {
default: 'claude-opus-4-6', default: 'claude-opus-4-6',
@@ -29,17 +29,14 @@ describe('buildAccountVisualGroups', () => {
]); ]);
expect(groups).toHaveLength(1); expect(groups).toHaveLength(1);
expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([ expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual(['business', 'free']);
'business',
'personal',
]);
expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([
'Business · Workspace 04a0f049', 'Business · Workspace 04a0f049',
'Personal · Free', 'Free',
]); ]);
expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([ expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([
'04a0f049', '04a0f049',
'Free', null,
]); ]);
expect(groups[0]?.memberIds).toEqual([ expect(groups[0]?.memberIds).toEqual([
'kaidu.kd@gmail.com#04a0f049-team', '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([ const groups = buildAccountVisualGroups([
makeAccount({ makeAccount({
id: 'kaidu.kd@gmail.com#plus', id: 'kaidu.kd@gmail.com#plus',
@@ -61,8 +58,8 @@ describe('buildAccountVisualGroups', () => {
expect(groups).toHaveLength(1); expect(groups).toHaveLength(1);
expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([
'Personal · Free',
'Personal · Plus', 'Personal · Plus',
'Free',
]); ]);
}); });
}); });
+5 -3
View File
@@ -16,12 +16,14 @@ describe('claude preset utils', () => {
vi.restoreAllMocks(); 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 claudeCatalog = MODEL_CATALOGS.claude;
const ids = claudeCatalog.models.map((model) => model.id);
expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6');
expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-opus-4-7'); expect(ids).toContain('claude-opus-4-8');
expect(claudeCatalog.models.map((model) => model.id)).toContain('claude-sonnet-4-6'); 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 () => { it('applies the default claude preset from the catalog default model mapping', async () => {