diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 3a4f33031c..5037a65ad9 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -62,10 +62,15 @@ const EXPECTED_CLAUDE_MODELS = [ model: "Opus 5", descriptionFragment: "Previous release", }, + { + id: "claude-fable-5-2", + model: "Fable 5.2", + descriptionFragment: "Most powerful", + }, { id: "claude-fable-5-1", model: "Fable 5.1", - descriptionFragment: "Most powerful", + descriptionFragment: "Previous release", }, { id: "claude-fable-5", @@ -199,11 +204,10 @@ function assertClaudeModels(data: ProviderModel[]): void { ); } - const fable51Index = data.findIndex((model) => model.id === "claude-fable-5-1"); - const fable5Index = data.findIndex((model) => model.id === "claude-fable-5"); - assert.strictEqual( - fable5Index, - fable51Index + 1, + const fable52Index = data.findIndex((model) => model.id === "claude-fable-5-2"); + assert.deepStrictEqual( + data.slice(fable52Index, fable52Index + 3).map((model) => model.id), + ["claude-fable-5-2", "claude-fable-5-1", "claude-fable-5"], "Fable models should stay adjacent and newest-first", ); assert( diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index e47fcb6da7..3aa96635fc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1614,6 +1614,8 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], ["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"], + ["Fable 5.2", "claude-fable-5-2", { type: "adaptive", display: "summarized" }, "medium"], + ["Mythos 5.2", "claude-mythos-5-2", { type: "adaptive", display: "summarized" }, "medium"], ["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined], ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], diff --git a/packages/server/src/server/agent/providers/claude/feature-definitions.ts b/packages/server/src/server/agent/providers/claude/feature-definitions.ts index 0606860745..46df4d94bb 100644 --- a/packages/server/src/server/agent/providers/claude/feature-definitions.ts +++ b/packages/server/src/server/agent/providers/claude/feature-definitions.ts @@ -5,7 +5,7 @@ export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = { type: "toggle", key: "fast_mode", label: "Fast", - description: "Lower latency Opus responses at higher token cost", + description: "Lower latency responses at higher token cost", tooltip: "Toggle fast mode", icon: "zap", }; diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index 45a5441109..d73ae34f7d 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -58,6 +58,17 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + id: "claude-mythos-5-2", + label: "Mythos 5.2", + requiresDiscovery: true, + description: "Mythos 5.2 · Requires verified access", + minimumClaudeCodeVersion: "2.1.321", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsFastMode: true, + }, { id: "claude-mythos-5-1", label: "Mythos 5.1", @@ -66,10 +77,20 @@ export const CLAUDE_MODEL_MANIFEST = [ contextWindowMaxTokens: 1_000_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, }, + { + id: "claude-fable-5-2", + label: "Fable 5.2", + description: "Fable 5.2 · Most powerful model", + minimumClaudeCodeVersion: "2.1.321", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsFastMode: true, + }, { id: "claude-fable-5-1", label: "Fable 5.1", - description: "Fable 5.1 · Most powerful model", + description: "Fable 5.1 · Previous release", contextWindowMaxTokens: 1_000_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, }, diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 464964cf3a..1287fd6335 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -61,17 +61,23 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, discoverModels: async () => [ { value: "mythos", - resolvedModel: "claude-mythos-5-1", + resolvedModel: "claude-mythos-5-2", displayName: "Mythos", description: "", }, + { + value: "claude-mythos-5-1", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos 5.1", + description: "", + }, ], }); } @@ -82,7 +88,9 @@ describe("getClaudeModels", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5-5", "claude-opus-5", + "claude-mythos-5-2", "claude-mythos-5-1", + "claude-fable-5-2", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -118,7 +126,9 @@ describe("getClaudeModels", () => { new Map([ ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], + ["claude-mythos-5-2", 1_000_000], ["claude-mythos-5-1", 1_000_000], + ["claude-fable-5-2", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], @@ -163,6 +173,12 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); }); + it("gates Fable 5.2 on Claude Code 2.1.321 without changing the default", () => { + expect(getClaudeModels("2.1.320").map((model) => model.id)).not.toContain("claude-fable-5-2"); + expect(getClaudeModels("2.1.321").map((model) => model.id)).toContain("claude-fable-5-2"); + expect(getClaudeModels("2.1.321").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); @@ -232,6 +248,8 @@ describe("getClaudeModels", () => { ["claude-sonnet-5-20260101", true, "high"], ["claude-fable-5", false, "high"], ["claude-fable-5-1", false, "high"], + ["claude-fable-5-2", false, "medium"], + ["claude-mythos-5-2", false, "medium"], ["claude-haiku-5-5", true, "medium"], ["claude-haiku-5-5-20261007", true, "medium"], ["claude-haiku-4-5", false, undefined], @@ -284,6 +302,31 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }, ); + it("offers Mythos 5.2 in place of Mythos 5.1 when Claude Code resolves mythos to 5.2", async () => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.321", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-2", + displayName: "Mythos", + description: "", + }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.filter((model) => model.id.startsWith("claude-mythos-"))).toEqual([ + findClaudeModel("claude-mythos-5-2"), + ]); + }); + it("preserves the curated catalog and explicitly configured models after discovery failure", async () => { const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" }); const client = new ClaudeAgentClient({ @@ -299,8 +342,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => { cwd: configDir, force: false, }); - expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual( - getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"), + expect(models.filter((model) => !model.id.startsWith("claude-mythos-"))).toEqual( + getClaudeModels("2.1.293").filter((model) => !model.id.startsWith("claude-mythos-")), ); expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({ ...findClaudeModel("claude-mythos-5-1"), @@ -360,7 +403,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }); expect(models.map((model) => model.id)).toEqual( getClaudeModels("2.1.293") - .filter((model) => model.id !== "claude-mythos-5-1") + .filter((model) => !model.id.startsWith("claude-mythos-")) .map((model) => model.id), ); }); @@ -785,6 +828,44 @@ describe("Claude Fable 5.1 catalog", () => { }); }); +describe("Claude Fable 5.2 catalog", () => { + it("offers one 1M model with medium default effort, always-on thinking, and fast mode", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-fable-5-2")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-fable-5-2", + label: "Fable 5.2", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "medium", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(claudeManifestModelSupportsFastMode("claude-fable-5-2")).toBe(true); + }); + + it.each([ + "claude-fable-5-2[1m]", + "claude-fable-5-2-20261110", + "claude-fable-5-2-20261110[1m]", + "Fable 5.2", + ])("resolves %s to the single catalog entry", (id) => { + expect(findClaudeModel(id)?.id).toBe("claude-fable-5-2"); + }); + + it.each(["claude-fable-5-2", "anthropic/claude-fable-5-2", "us.anthropic.claude-fable-5-2-v1:0"])( + "keeps runtime model %s from collapsing onto Fable 5", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-fable-5-2"); + }, + ); +}); + describe("Claude Opus 5.5 catalog", () => { it("offers one Opus 5.5 entry with a 1M context window", () => { const opus55Models = getClaudeModels() @@ -920,7 +1001,7 @@ describe("claudeManifestModelSupportsFastMode", () => { describe("Claude Mythos 5.1 catalog", () => { it("offers one 1M model with high default effort and always-on thinking", () => { - const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos")); + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos-5-1")); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: "claude-mythos-5-1", @@ -965,6 +1046,61 @@ describe("Claude Mythos 5.1 catalog", () => { ); }); +describe("Claude Mythos 5.2 catalog", () => { + it("stays hidden on Claude Code older than 2.1.321 even when advertised", async () => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.320", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-2", + displayName: "Mythos", + description: "", + }, + ], + }); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + + expect(models.some((model) => model.id === "claude-mythos-5-2")).toBe(false); + }); + + it("offers one 1M model with medium default effort, always-on thinking, and fast mode", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos-5-2")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-mythos-5-2", + label: "Mythos 5.2", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "medium", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(claudeManifestModelSupportsFastMode("claude-mythos-5-2")).toBe(true); + }); + + it.each(["anthropic/claude-mythos-5-2", "us.anthropic.claude-mythos-5-2-v1:0"])( + "normalizes runtime identity without granting first-party capabilities: %s", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-2"); + expect(normalizeClaudeManifestModelId(id)).toBeNull(); + }, + ); +}); + describe("Claude model discovery process", () => { it("does not send a prompt or persist a session and closes after discovery", async () => { let closed = false;