diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index aad1654ecd..dfaff75b8c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -447,6 +447,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Fable 5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-fable-5", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index badbf98905..3b3eb99c84 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -48,50 +48,42 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const CLAUDE_EFFORT_OPTIONS = { - opus48: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultracode", label: "Ultracode" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - opus47: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - opus46: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - sonnet46: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - opus45: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], -} as const; - const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-fable-5", + name: "Claude Fable 5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultracode", label: "Ultracode" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }), + ], + }), + }, { slug: "claude-opus-4-8", name: "Claude Opus 4.8", @@ -101,9 +93,21 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "effort", label: "Reasoning", - options: CLAUDE_EFFORT_OPTIONS.opus48, + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultracode", label: "Ultracode" }, + { value: "ultrathink", label: "Ultrathink" }, + ], promptInjectedValues: ["ultrathink"], }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", @@ -124,9 +128,20 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "effort", label: "Reasoning", - options: CLAUDE_EFFORT_OPTIONS.opus47, + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], promptInjectedValues: ["ultrathink"], }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), buildSelectOptionDescriptor({ id: "contextWindow", label: "Context Window", @@ -147,7 +162,13 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "effort", label: "Reasoning", - options: CLAUDE_EFFORT_OPTIONS.opus46, + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], promptInjectedValues: ["ultrathink"], }), buildBooleanOptionDescriptor({ @@ -174,7 +195,12 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "effort", label: "Reasoning", - options: CLAUDE_EFFORT_OPTIONS.opus45, + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + ], }), buildBooleanOptionDescriptor({ id: "fastMode", @@ -192,7 +218,13 @@ const BUILT_IN_MODELS: ReadonlyArray = [ buildSelectOptionDescriptor({ id: "effort", label: "Reasoning", - options: CLAUDE_EFFORT_OPTIONS.sonnet46, + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], promptInjectedValues: ["ultrathink"], }), buildSelectOptionDescriptor({ @@ -221,6 +253,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeFable5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; +} + function supportsClaudeOpus48(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; } @@ -233,6 +269,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-fable-5") { + return supportsClaudeFable5(version); + } if (model.slug === "claude-opus-4-8") { return supportsClaudeOpus48(version); } @@ -243,6 +282,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeFable5UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; +} + function formatClaudeOpus48UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; @@ -294,7 +338,7 @@ export function normalizeClaudeCliEffort( if (effort === "ultracode") { return "xhigh"; } - if (effort === "xhigh" && model !== "claude-opus-4-8") { + if (effort === "xhigh" && model !== "claude-fable-5" && model !== "claude-opus-4-8") { return "max"; } if (effort === "max" && model === "claude-sonnet-4-6") { @@ -688,11 +732,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus48(parsedVersion) + const versionUpgradeMessage = supportsClaudeFable5(parsedVersion) ? undefined - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 8621ec06b5..273cf22f31 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1367,6 +1367,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); + assert.strictEqual(fable5?.name, "Claude Fable 5"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Fable 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect( "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", () =>