diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index cf0f8351d..73c6f233a 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -617,6 +617,45 @@ describe('ClaudeProvider.complete request shape', () => { }); }); + it('omits temperature for fable 5.1 (rejects non-default sampling)', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-fable-5-1', + messages: [{ role: 'user', content: 'hi' }], + temperature: 0.5, + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + expect('temperature' in args).toBe(false); + }); + + it('forwards reasoning_effort as adaptive thinking + output_config effort on fable 5.1', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-fable-5-1', + messages: [{ role: 'user', content: 'hi' }], + reasoning_effort: 'high', + } as never), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + // Fable 5.1 rejects `budget_tokens` and thinking is always on, so the + // only accepted config is adaptive; effort rides in output_config. + expect(args.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }); + expect(args.output_config).toEqual({ effort: 'high' }); + expect('temperature' in args).toBe(false); + }); + it('builds an enabled thinking budget from reasoning_effort on older Sonnet models', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); @@ -670,6 +709,22 @@ describe('ClaudeProvider model resolution', () => { ); }); + it('routes the bare claude-fable alias to fable 5.1 rather than fable 5', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-fable', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + expect(messagesCreateMock.mock.calls[0]![0].model).toBe( + 'claude-fable-5-1', + ); + }); + it('falls back to the default model when given an unknown id', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); @@ -736,6 +791,46 @@ describe('ClaudeProvider.complete non-stream output', () => { ); }); + it('meters fable 5.1 cache reads at its reduced rate, not the 0.1x used elsewhere', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce({ + content: [{ type: 'text', text: 'ok' }], + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 1000, + }, + }); + + await withTestActor(() => + provider.complete({ + model: 'claude-fable-5-1', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + const fable51 = CLAUDE_MODELS.find((m) => m.id === 'claude-fable-5-1')!; + const fable5 = CLAUDE_MODELS.find((m) => m.id === 'claude-fable-5')!; + // Same per-token price as Fable 5 except cache reads at a quarter of the rate. + expect(fable51.costs.input_tokens).toBe(fable5.costs.input_tokens); + expect(fable51.costs.output_tokens).toBe(fable5.costs.output_tokens); + expect(Number(fable51.costs.cache_read_input_tokens)).toBeCloseTo( + Number(fable5.costs.cache_read_input_tokens) / 4, + ); + + const [, , prefix, overrides] = recordSpy.mock.calls[0]!; + expect(prefix).toBe('claude:claude-fable-5-1'); + expect(overrides.input_tokens).toBe( + 100 * Number(fable51.costs.input_tokens), + ); + expect(overrides.output_tokens).toBe( + 50 * Number(fable51.costs.output_tokens), + ); + expect(overrides.cache_read_input_tokens).toBeCloseTo( + 1000 * Number(fable51.costs.cache_read_input_tokens), + ); + }); + it('bills the compaction pass by summing usage.iterations', async () => { const { provider } = makeProvider(); // Per Anthropic: top-level input/output reflect only the message pass; diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index e5ab3c1cd..819281e93 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -298,9 +298,10 @@ export class ClaudeProvider implements IChatProvider { reasoningEffort: requestedReasoningEffort, maxTokens: max_tokens, }); - // Fable 5 and Opus 4.7/4.8 error on non-default sampling params; omit temperature entirely. + // Fable 5/5.1, Sonnet 5, and Opus 4.7+ reject non-default sampling; omit temperature entirely. // Other models require temperature=1 when thinking is enabled. const omitsTemperature = [ + 'claude-fable-5-1', 'claude-fable-5', 'claude-sonnet-5', 'claude-opus-4-7', @@ -313,6 +314,7 @@ export class ClaudeProvider implements IChatProvider { ? 1 : (temperature ?? 0); const supportsEffort = [ + 'claude-fable-5-1', 'claude-fable-5', 'claude-sonnet-5', 'claude-opus-5', @@ -717,12 +719,13 @@ export class ClaudeProvider implements IChatProvider { }) { if (!reasoningEffort) return undefined; - // Fable 5, Opus 4.7/4.8, 4.6, and Sonnet 4.6 use adaptive thinking + // Fable 5/5.1, Opus 4.7+, 4.6, and Sonnet 4.6 use adaptive thinking // (`budget_tokens` is deprecated on 4.6/Sonnet 4.6, removed on - // Fable 5 and 4.7+). Fable 5 and Opus 4.7/4.8 omit thinking content - // by default; `display: 'summarized'` restores visible reasoning in - // the stream. + // Fable 5+ and Opus 4.7+). Fable 5/5.1 and Opus 4.7+ omit thinking + // content by default; `display: 'summarized'` restores visible + // reasoning in the stream. if ( + modelId === 'claude-fable-5-1' || modelId === 'claude-fable-5' || modelId === 'claude-opus-5' || modelId === 'claude-opus-4-8' || diff --git a/src/backend/drivers/ai-chat/providers/claude/models.ts b/src/backend/drivers/ai-chat/providers/claude/models.ts index 06a013c9c..a54fc38cf 100644 --- a/src/backend/drivers/ai-chat/providers/claude/models.ts +++ b/src/backend/drivers/ai-chat/providers/claude/models.ts @@ -21,6 +21,36 @@ import type { IChatModel } from '../../types.js'; // Hardcoded from https://models.dev/api.json export const CLAUDE_MODELS: IChatModel[] = [ + { + puterId: 'anthropic:anthropic/claude-fable-5-1', + id: 'claude-fable-5-1', + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-09-01', + aliases: [ + 'claude-fable', + 'claude-fable-latest', + 'claude-fable-5-1-latest', + 'claude-fable-5.1', + 'anthropic/claude-fable-5-1', + ], + name: 'Claude Fable 5.1', + costs_currency: 'usd-cents', + input_cost_key: 'input_tokens', + output_cost_key: 'output_tokens', + costs: { + tokens: 1_000_000, + input_tokens: 1000, + ephemeral_5m_input_tokens: 1000 * 1.25, + ephemeral_1h_input_tokens: 1000 * 2, + // Fable 5.1 bills cache reads at 0.025x input; every other Claude model is 0.1x. + cache_read_input_tokens: 1000 * 0.025, + output_tokens: 5000, + }, + context: 1000000, + max_tokens: 128000, + }, { puterId: 'anthropic:anthropic/claude-fable-5', id: 'claude-fable-5', @@ -28,12 +58,7 @@ export const CLAUDE_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, release_date: '2026-06-09', - aliases: [ - 'claude-fable', - 'claude-fable-latest', - 'claude-fable-5-latest', - 'anthropic/claude-fable-5', - ], + aliases: ['claude-fable-5-latest', 'anthropic/claude-fable-5'], name: 'Claude Fable 5', costs_currency: 'usd-cents', input_cost_key: 'input_tokens',