diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index bb3c3bdd..eae74a31 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -168,6 +168,19 @@ describe('Model Catalog', () => { assert.strictEqual(opus48.extendedContext, true); }); + it('includes Claude Fable 5 with adaptive levels and extended context', () => { + const { MODEL_CATALOG } = modelCatalog; + const fable5 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-fable-5'); + assert(fable5, 'Should include Claude Fable 5'); + assert.strictEqual(fable5.name, 'Claude Fable 5'); + // New top tier above Opus; same adaptive thinking surface as Opus 4.8: + // Anthropic only accepts effort levels, budget_tokens is rejected with 400. + assert.strictEqual(fable5.thinking.type, 'levels'); + assert.deepStrictEqual(fable5.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(fable5.thinking.maxLevel, 'max'); + assert.strictEqual(fable5.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 221eca94..8dfd47c9 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -60,6 +60,15 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Claude Fable 5', () => { + // Fable 5 shares Opus 4.8's adaptive thinking surface; max must remain + // distinct from xhigh. + const result = validateThinking('claude', 'claude-fable-5', '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/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index da871e40..f8b09015 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -63,6 +63,7 @@ function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean { */ export function detectTierFromModel(modelName: string): ModelTier { const lower = modelName.toLowerCase(); + if (lower.includes('fable')) return 'opus'; // Fable is the top tier; thinking defaults match Opus if (lower.includes('opus')) return 'opus'; if (lower.includes('haiku')) return 'haiku'; return 'sonnet'; // Default to sonnet (most common) diff --git a/src/cliproxy/executor/__tests__/composite-thinking.test.ts b/src/cliproxy/executor/__tests__/composite-thinking.test.ts index f4d7a592..0158e101 100644 --- a/src/cliproxy/executor/__tests__/composite-thinking.test.ts +++ b/src/cliproxy/executor/__tests__/composite-thinking.test.ts @@ -65,6 +65,13 @@ describe('detectTierFromModel', () => { expect(result).toBe('sonnet'); }); + it('should map Claude Fable 5 to the opus tier', () => { + // Fable is the top tier above Opus; auto-mode thinking defaults should + // match the opus tier (effort high), not the sonnet fallback. + const result = detectTierFromModel('claude-fable-5'); + expect(result).toBe('opus'); + }); + it('should detect haiku from model name containing "haiku"', () => { const result = detectTierFromModel('claude-haiku-4-5-20251001'); expect(result).toBe('haiku'); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 2192107a..5c2ade81 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -369,6 +369,21 @@ export const MODEL_CATALOG: Partial> = displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-fable-5', + name: 'Claude Fable 5', + description: 'Most powerful model', + nativeImageInput: true, + // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: + // Anthropic accepts only effort levels; 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-8', name: 'Claude Opus 4.8', diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 7c01c32e..2f36e1a8 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -316,6 +316,13 @@ const PRICING_REGISTRY: Record = { fast: OPUS_48_FAST_RATES, }, }, + // Claude Fable 5 ($10/$50) — most powerful tier, above Opus + 'claude-fable-5': { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, + cacheReadPerMillion: 1.0, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 16652330..dfafe478 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -245,6 +245,14 @@ describe('model-pricing', () => { expect(opus48.outputPerMillion).toBe(25.0); }); + it('should return correct pricing for Claude Fable 5', () => { + const fable5 = getModelPricing('claude-fable-5'); + expect(fable5.inputPerMillion).toBe(10.0); + expect(fable5.outputPerMillion).toBe(50.0); + expect(fable5.cacheCreationPerMillion).toBe(12.5); + expect(fable5.cacheReadPerMillion).toBe(1.0); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -355,6 +363,17 @@ describe('model-pricing', () => { expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); + it('should calculate Claude Fable 5 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-fable-5'); + expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0 + }); + it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { const usage: TokenUsage = { inputTokens: 1_000_000, diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 8059da26..4292774c 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -752,6 +752,18 @@ export const MODEL_CATALOGS: Record = { displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-6', models: [ + { + id: 'claude-fable-5', + name: 'Claude Fable 5', + description: 'Most powerful model', + extendedContext: true, + presetMapping: { + default: 'claude-fable-5', + opus: 'claude-fable-5', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index d77a7a4d..54ff955e 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -16,11 +16,12 @@ describe('claude preset utils', () => { vi.restoreAllMocks(); }); - it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7 and 4.8', () => { + it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7/4.8 and Fable 5', () => { const claudeCatalog = MODEL_CATALOGS.claude; const ids = claudeCatalog.models.map((model) => model.id); expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); + expect(ids).toContain('claude-fable-5'); expect(ids).toContain('claude-opus-4-8'); expect(ids).toContain('claude-opus-4-7'); expect(ids).toContain('claude-sonnet-4-6');