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 () => {