diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f815ac75be34..745189a53b5c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -52,11 +52,49 @@ const CLAUDE_PRESENTATION = { showInteractionModeToggle: true, } as const; const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; +// Fork-local gate: earliest Claude Code version verified to accept +// `claude-fable-5-1` on this fork. Revisit when upstream ships its own entry. +const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; 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_MODEL_CATALOG: ReadonlyArray = [ + { + slug: "claude-fable-5-1", + name: "Claude Fable 5.1", + 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", + description: "xhigh effort plus multi-agent workflow orchestration", + }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-fable-5", name: "Claude Fable 5", @@ -329,6 +367,10 @@ function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } +function supportsClaudeFable51(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; +} + function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } @@ -345,6 +387,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-fable-5-1") { + return supportsClaudeFable51(version); + } if (model.slug === "claude-opus-5") { return supportsClaudeOpus5(version); } @@ -361,6 +406,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeFable51UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; @@ -424,6 +474,7 @@ export function normalizeClaudeCliEffort( } if ( effort === "xhigh" && + model !== "claude-fable-5-1" && model !== "claude-fable-5" && model !== "claude-opus-5" && model !== "claude-opus-4-8" && @@ -907,15 +958,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) + const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeOpus5(parsedVersion) + ? formatClaudeFable51UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(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 663ee90368b8..121192b7ac3d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1826,6 +1826,63 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); + assert.strictEqual(fable51?.name, "Claude Fable 5.1"); + assert.strictEqual(status.message, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.257\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.1 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-1"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.256\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 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index fdcfa9335424..e531d1f57300 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -59,6 +59,15 @@ describe("isLegacyModel (bundled manifest)", () => { it("leaves driver kinds without a manifest entry unflagged", () => { assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); }); + + it("keeps fork-added models current even when the manifest omits them", () => { + const upstreamManifest: ModelManifestData = { + version: 1, + currentModels: { claudeAgent: ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] }, + }; + assert.isFalse(isLegacyModel(upstreamManifest, CLAUDE, "claude-fable-5-1")); + assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, "claude-fable-5-1")); + }); }); const model = (overrides: Partial): ServerProviderModel => ({ diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index cb9494992287..e1470b2a8447 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -75,12 +75,23 @@ const encodeManifestCache = Schema.encodeEffect( ), ); +/** + * Fork-local catalog additions upstream's manifest does not know about yet. + * The remote manifest is fetched from upstream `main`, so without this + * override every fork-added model would land in the legacy section. Remove a + * slug once upstream's manifest lists it. + */ +const FORK_CURRENT_MODELS: Partial>> = { + claudeAgent: ["claude-fable-5-1"], +}; + /** True when the manifest classifies `slug` as legacy for `driverKind`. */ export function isLegacyModel( manifest: ModelManifestData, driverKind: ProviderDriverKind, slug: string, ): boolean { + if (FORK_CURRENT_MODELS[driverKind]?.includes(slug)) return false; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; return !currentModels.includes(slug); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..b711a97c92c6 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -177,6 +177,8 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "gpt-5.3-spark": "gpt-5.3-codex-spark", }, [CLAUDE_DRIVER_KIND]: { + "fable-5.1": "claude-fable-5-1", + "claude-fable-5.1": "claude-fable-5-1", opus: "claude-opus-5", "opus-5": "claude-opus-5", "claude-opus-5.0": "claude-opus-5",