Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderModel> = [
{
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",
Expand Down Expand Up @@ -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;
}
Expand All @@ -345,6 +387,9 @@ function getBuiltInClaudeModelsForVersion(
version: string | null | undefined,
): ReadonlyArray<ServerProviderModel> {
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);
}
Expand All @@ -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.`;
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -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))
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/provider/ModelManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): ServerProviderModel => ({
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/provider/ModelManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, ReadonlyArray<string>>> = {
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);
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading