From e288521a985c18385ebe6083e1350be836ad84c2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:55:32 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20promote=20Claude=20F?= =?UTF-8?q?able=205.1=20as=20the=20fable=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/config/models.mdx | 2 +- src/common/config/schemas/appConfigOnDisk.ts | 6 ++ src/common/constants/knownModels.ts | 10 +-- src/common/utils/ai/modelDisplay.test.ts | 1 + src/common/utils/ai/modelFallbacks.ts | 4 +- src/common/utils/ai/models.test.ts | 8 +- src/common/utils/ai/models.ts | 1 + src/common/utils/ai/providerOptions.test.ts | 9 +++ src/common/utils/thinking/policy.test.ts | 8 ++ src/common/utils/tokens/models-extra.ts | 19 +++++ src/common/utils/tools/tools.test.ts | 1 + src/node/config.test.ts | 75 +++++++++++++++++-- src/node/config.ts | 14 +++- .../builtInSkillContent.generated.ts | 2 +- .../services/workflows/WorkflowRunner.test.ts | 2 +- 15 files changed, 142 insertions(+), 20 deletions(-) diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 419edfbe1a1..79f56e53876 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -13,7 +13,7 @@ Xum ships with curated models kept up to date with the frontier. Use any custom | Model | ID | Aliases | Default | | ---------------------- | ----------------------------- | ------------------------------------------------------------ | ------- | -| Fable 5 | anthropic:claude-fable-5 | `fable` | | +| Fable 5.1 | anthropic:claude-fable-5-1 | `fable` | | | Mythos 5 | anthropic:claude-mythos-5 | `mythos` | | | Opus 5 | anthropic:claude-opus-5 | `opus` | ✓ | | Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | | diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 4a26dc81457..2d20eb5b499 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -117,6 +117,12 @@ export const AppConfigMigrationsSchema = z userPreferencesInitialized: z.boolean().optional(), /** One-time seed of DEFAULT_MODEL_FALLBACKS; not re-applied while true. */ defaultModelFallbacksSeeded: z.boolean().optional(), + /** + * One-time re-run of the fallback seed after the fable alias moved to + * Fable 5.1: configs seeded before the promotion lack a chain for the new + * source key. + */ + defaultModelFallbacksSeededFable51: z.boolean().optional(), /** One-time migration from the legacy auto-delete default to persistent sub-agents. */ persistentSubagentsDefaulted: z.boolean().optional(), }) diff --git a/src/common/constants/knownModels.ts b/src/common/constants/knownModels.ts index 7fb89c56666..a096edf7a5d 100644 --- a/src/common/constants/knownModels.ts +++ b/src/common/constants/knownModels.ts @@ -27,13 +27,13 @@ interface KnownModel extends KnownModelDefinition { // Model definitions. Note we avoid listing legacy models here. These represent the focal models // of the community. const MODEL_DEFINITIONS = { - // Claude Fable 5 - Mythos-class model (a tier above Opus) released June 9, 2026. - // It is the generally-available variant of the Mythos 5 model, shipped with safeguards - // enabled (a small fraction of flagged requests fall back to Opus 4.8 server-side, which - // is transparent to API clients). API id `claude-fable-5`; $10/M input, $50/M output. + // Claude Fable 5.1 - Mythos-class model (a tier above Opus), successor to Fable 5 + // (released June 9, 2026) as the generally-available safeguarded variant, at unchanged + // pricing ($10/M input, $50/M output). API id `claude-fable-5-1`; Fable 5 stays usable + // as the custom model string `anthropic:claude-fable-5`. FABLE: { provider: "anthropic", - providerModelId: "claude-fable-5", + providerModelId: "claude-fable-5-1", aliases: ["fable"], warm: true, // Fable/Mythos use the newer Opus 4.7+ tokenizer, which isn't published upstream; diff --git a/src/common/utils/ai/modelDisplay.test.ts b/src/common/utils/ai/modelDisplay.test.ts index 0aa441b4403..f5e4cc59287 100644 --- a/src/common/utils/ai/modelDisplay.test.ts +++ b/src/common/utils/ai/modelDisplay.test.ts @@ -27,6 +27,7 @@ describe("formatModelDisplayName", () => { test("formats Mythos-class Fable / Mythos models", () => { expect(formatModelDisplayName("claude-fable-5")).toBe("Fable 5"); + expect(formatModelDisplayName("claude-fable-5-1")).toBe("Fable 5.1"); expect(formatModelDisplayName("claude-mythos-5")).toBe("Mythos 5"); }); }); diff --git a/src/common/utils/ai/modelFallbacks.ts b/src/common/utils/ai/modelFallbacks.ts index 428aaae6802..bb025f9dd08 100644 --- a/src/common/utils/ai/modelFallbacks.ts +++ b/src/common/utils/ai/modelFallbacks.ts @@ -16,7 +16,9 @@ export const MODEL_FALLBACK_CHAIN_LIMIT = 3; * the sensible out-of-the-box behavior. * * Seeded into the config exactly once, guarded by - * migrations.defaultModelFallbacksSeeded — on versions that know the flag, + * migrations.defaultModelFallbacksSeeded (plus the one-shot + * defaultModelFallbacksSeededFable51 re-seed for the key move to Fable 5.1) + * — on versions that know the flag, * user edits or deletions of these chains are never overridden by updates. * (Versions predating the flag strip it on save, so a downgrade→save→ * re-upgrade round-trip re-seeds a deleted chain; bounded to re-adding this diff --git a/src/common/utils/ai/models.test.ts b/src/common/utils/ai/models.test.ts index f4eab11f218..8abea904a54 100644 --- a/src/common/utils/ai/models.test.ts +++ b/src/common/utils/ai/models.test.ts @@ -167,12 +167,14 @@ describe("Anthropic 1M context classification", () => { expect(hasNative1MContext("anthropic:claude-sonnet-5")).toBe(true); }); - it("treats Mythos-class Fable 5 / Mythos 5 as native 1M models", () => { + it("treats Mythos-class Fable 5 / Fable 5.1 / Mythos 5 as native 1M models", () => { expect(getAnthropic1MContextMode("anthropic:claude-fable-5")).toBe("native"); + expect(getAnthropic1MContextMode("anthropic:claude-fable-5-1")).toBe("native"); expect(getAnthropic1MContextMode("anthropic:claude-mythos-5")).toBe("native"); - expect(getAnthropic1MContextMode("mux-gateway:anthropic/claude-fable-5")).toBe("native"); + expect(getAnthropic1MContextMode("mux-gateway:anthropic/claude-fable-5-1")).toBe("native"); expect(hasNative1MContext("anthropic:claude-fable-5")).toBe(true); - expect(supports1MContext("anthropic:claude-fable-5")).toBe(false); + expect(hasNative1MContext("anthropic:claude-fable-5-1")).toBe(true); + expect(supports1MContext("anthropic:claude-fable-5-1")).toBe(false); }); it("returns none for models without Anthropic 1M support", () => { diff --git a/src/common/utils/ai/models.ts b/src/common/utils/ai/models.ts index f4b4d614ea7..a58d7432b98 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -179,6 +179,7 @@ const OPTIONAL_VERSION_SUFFIX = String.raw`(?:-(?:\d{8}|\d{4}-\d{2}-\d{2}))?`; const ANTHROPIC_NATIVE_1M_PATTERNS = [ // Mythos-class models (Fable 5 / Mythos 5) ship 1M context as standard metadata. new RegExp(`^claude-fable-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), + new RegExp(`^claude-fable-5-1${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-mythos-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-opus-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-opus-4-8${OPTIONAL_VERSION_SUFFIX}$`, "i"), diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 8467dbe0ca0..6fe8c7658b3 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -180,6 +180,12 @@ describe("buildProviderOptions - Anthropic", () => { // the summarized display flag. expect(anthropic.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(anthropic.effort).toBe("medium"); + // Fable 5.1 rides the same Mythos-class wildcard matcher. + const anthropic51 = anthropicProviderOptions( + buildProviderOptions("anthropic:claude-fable-5-1", "medium") + ); + expect(anthropic51.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(anthropic51.effort).toBe("medium"); }); test("omits thinking instead of sending disabled when off", () => { @@ -189,6 +195,9 @@ describe("buildProviderOptions - Anthropic", () => { expect(buildProviderOptions("anthropic:claude-fable-5", "off")).toEqual({ anthropic: { ...baseAnthropicOptions, effort: "low" }, }); + expect(buildProviderOptions("anthropic:claude-fable-5-1", "off")).toEqual({ + anthropic: { ...baseAnthropicOptions, effort: "low" }, + }); }); }); diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index baa3862272b..6c58576bf13 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -443,6 +443,13 @@ describe("getThinkingPolicyForModel", () => { "xhigh", "max", ]); + expect(getThinkingPolicyForModel("anthropic:claude-fable-5-1")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); expect(getThinkingPolicyForModel("anthropic:claude-mythos-5")).toEqual([ "low", "medium", @@ -455,6 +462,7 @@ describe("getThinkingPolicyForModel", () => { test("clamps 'off' up to 'low' for Mythos-class models", () => { // A stored/legacy "off" selection must not reach the wire as disabled thinking. expect(enforceThinkingPolicy("anthropic:claude-fable-5", "off")).toBe("low"); + expect(enforceThinkingPolicy("anthropic:claude-fable-5-1", "off")).toBe("low"); expect(enforceThinkingPolicy("anthropic:claude-mythos-5", "off")).toBe("low"); }); diff --git a/src/common/utils/tokens/models-extra.ts b/src/common/utils/tokens/models-extra.ts index e28a1bc340b..81beb9a31b3 100644 --- a/src/common/utils/tokens/models-extra.ts +++ b/src/common/utils/tokens/models-extra.ts @@ -135,6 +135,25 @@ export const modelsExtra: Record = { supports_pdf_input: true, }, + // Claude Fable 5.1 - successor to Fable 5 in the Mythos-class tier at the same + // pricing/shape: $10/M input, $50/M output, cache write 1.25x input / cache read + // 0.1x input, native 1M context, 128K max output, native xhigh effort level. + "claude-fable-5-1": { + max_input_tokens: 1000000, + max_output_tokens: 128000, + input_cost_per_token: 0.00001, // $10 per million input tokens + output_cost_per_token: 0.00005, // $50 per million output tokens + cache_creation_input_token_cost: 0.0000125, // $12.50 per million tokens (1.25× input) + cache_read_input_token_cost: 0.000001, // $1.00 per million tokens (0.1× input) + litellm_provider: "anthropic", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_pdf_input: true, + supports_reasoning: true, + supports_response_schema: true, + }, + // Claude Fable 5 / Mythos 5 - Released June 9, 2026 // Mythos-class model (a tier above Opus). Fable 5 (`claude-fable-5`) is the // generally-available variant with safeguards; Mythos 5 (`claude-mythos-5`) is the same diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts index fd5fb8fcf77..98f281c733f 100644 --- a/src/common/utils/tools/tools.test.ts +++ b/src/common/utils/tools/tools.test.ts @@ -72,6 +72,7 @@ describe("supportsAnthropicNativeWebFetch", () => { ["claude-fable-5", true], ["claude-mythos-5", true], // Two-segment IDs at/after the 4.6 cutoff. + ["claude-fable-5-1", true], ["claude-sonnet-4-6", true], ["claude-opus-4-6", true], ["claude-opus-4-8", true], diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 484f9fb3069..5481dab8819 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -113,6 +113,7 @@ describe("Config", () => { taskSettings: { preserveSubagentsUntilArchive: true }, migrations: { defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, persistentSubagentsDefaulted: true, }, }) @@ -1551,7 +1552,10 @@ describe("Config", () => { JSON.stringify({ projects: [], // Keep this test focused on normalization, not default seeding. - migrations: { defaultModelFallbacksSeeded: true }, + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, modelFallbacks: { // Gateway-prefixed key + non-string chain entries + unknown trigger. "openrouter:anthropic/claude-opus-4-6": { @@ -1606,13 +1610,69 @@ describe("Config", () => { configFilePath(), JSON.stringify({ projects: [], - migrations: { defaultModelFallbacksSeeded: true }, + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, }) ); expect(config.loadConfigOrDefault().modelFallbacks).toBeUndefined(); }); + it("seeds the Fable 5.1 chain once for configs seeded before the 5.1 promotion", async () => { + // Pre-5.1 configs carry a chain only for the old source key + // (anthropic:claude-fable-5); the promoted FABLE key must get its own + // one-time seed without touching the legacy chain. + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { defaultModelFallbacksSeeded: true }, + modelFallbacks: { + "anthropic:claude-fable-5": { models: ["anthropic:claude-opus-4-8"] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + "anthropic:claude-fable-5": { models: ["anthropic:claude-opus-4-8"] }, + [FABLE]: { models: [OPUS] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededFable51).toBe(true); + + await flushConfigEdits(); + const raw = JSON.parse(fs.readFileSync(configFilePath(), "utf-8")) as { + modelFallbacks?: unknown; + migrations?: { defaultModelFallbacksSeededFable51?: unknown }; + }; + expect(raw.modelFallbacks).toEqual({ + "anthropic:claude-fable-5": { models: ["anthropic:claude-opus-4-8"] }, + [FABLE]: { models: [OPUS] }, + }); + expect(raw.migrations?.defaultModelFallbacksSeededFable51).toBe(true); + }); + + it("does not overwrite an existing Fable 5.1 chain during the 5.1 re-seed", () => { + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { defaultModelFallbacksSeeded: true }, + modelFallbacks: { + [FABLE]: { models: ["openai:gpt-5.5"] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + [FABLE]: { models: ["openai:gpt-5.5"] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededFable51).toBe(true); + }); + it("merges the seeded default with pre-existing chains for other source models", async () => { fs.writeFileSync( configFilePath(), @@ -1649,7 +1709,7 @@ describe("Config", () => { JSON.stringify({ projects: [], modelFallbacks: { - "openrouter:anthropic/claude-fable-5": { models: ["openai:gpt-5.5"] }, + "openrouter:anthropic/claude-fable-5-1": { models: ["openai:gpt-5.5"] }, }, }) ); @@ -2687,9 +2747,12 @@ describe("Config", () => { routeOverrides: { "openai:gpt-4o": "direct", }, - // Without this flag the one-time default-fallbacks seed would write - // the file, which is not the rewrite this test guards against. - migrations: { defaultModelFallbacksSeeded: true }, + // Without these flags the one-time default-fallbacks seeds would + // write the file, which is not the rewrite this test guards against. + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, }) ); diff --git a/src/node/config.ts b/src/node/config.ts index 596c1388a3c..e1dedc7093d 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1356,12 +1356,20 @@ export class Config { const minThinkingLevelByModel = normalizeMinThinkingLevelByModel( parsed.minThinkingLevelByModel ); - // One-time seed of the default refusal-fallback chains (e.g. Fable 5 → + // One-time seed of the default refusal-fallback chains (e.g. Fable → // Opus). Guarded by migrations.defaultModelFallbacksSeeded so the // seed is applied exactly once: users who later edit or delete the // default chains are not overridden on subsequent loads/updates. + // defaultModelFallbacksSeededFable51 re-runs the gap-check once after + // the fable alias moved to Fable 5.1: pre-5.1 configs only have a + // chain for the old source key, and the new key is a new default that + // deserves one seed of its own (still never overwriting an existing + // 5.1 chain). const migrationsBeforeSeed = normalizeConfigMigrations(parsed.migrations); - if (migrationsBeforeSeed.defaultModelFallbacksSeeded !== true) { + if ( + migrationsBeforeSeed.defaultModelFallbacksSeeded !== true || + migrationsBeforeSeed.defaultModelFallbacksSeededFable51 !== true + ) { // Gap-check against the RAW on-disk map with canonicalized keys, not // the sanitized map: a hand-edited entry whose chain sanitizes away // (e.g. {enabled:false, models:[]}) is still user intent and must not @@ -1391,6 +1399,7 @@ export class Config { parsed.migrations = { ...migrationsBeforeSeed, defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, }; configModified = true; } @@ -1574,6 +1583,7 @@ export class Config { modelFallbacks: { ...DEFAULT_MODEL_FALLBACKS }, migrations: { defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, persistentSubagentsDefaulted: true, }, }; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 891b5cc5a1e..9148b88adf5 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3850,7 +3850,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "| Model | ID | Aliases | Default |", "| ---------------------- | ----------------------------- | ------------------------------------------------------------ | ------- |", - "| Fable 5 | anthropic:claude-fable-5 | `fable` | |", + "| Fable 5.1 | anthropic:claude-fable-5-1 | `fable` | |", "| Mythos 5 | anthropic:claude-mythos-5 | `mythos` | |", "| Opus 5 | anthropic:claude-opus-5 | `opus` | ✓ |", "| Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | |", diff --git a/src/node/services/workflows/WorkflowRunner.test.ts b/src/node/services/workflows/WorkflowRunner.test.ts index ceb2035252c..197eafe1d49 100644 --- a/src/node/services/workflows/WorkflowRunner.test.ts +++ b/src/node/services/workflows/WorkflowRunner.test.ts @@ -369,7 +369,7 @@ describe("WorkflowRunner", () => { expect.objectContaining({ id: "verify", agentId: "exec", - modelString: "anthropic:claude-fable-5", + modelString: "anthropic:claude-fable-5-1", thinkingLevel: "high", }), ]); From 8a2d5ca014f39dc612b9ae9019f0574efc02fec2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:08:23 +0000 Subject: [PATCH 2/3] fix: retain approximate-tokenizer overrides for retired fable-5/opus-4-8 ids --- src/common/constants/knownModels.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/common/constants/knownModels.ts b/src/common/constants/knownModels.ts index a096edf7a5d..5a4a3f90c9d 100644 --- a/src/common/constants/knownModels.ts +++ b/src/common/constants/knownModels.ts @@ -275,11 +275,23 @@ export const MODEL_ABBREVIATIONS: Record = Object.fromEntries( .sort(([a], [b]) => a.localeCompare(b)) ); -export const TOKENIZER_MODEL_OVERRIDES: Record = Object.fromEntries( - Object.values(KNOWN_MODELS) - .filter((model) => Boolean(model.tokenizerOverride)) - .map((model) => [model.id, model.tokenizerOverride!]) -); +// Retired first-class models stay documented as custom model strings (see the +// FABLE/OPUS comments); keep their approximate-tokenizer overrides so exact-id +// lookup does not fall back to the generic per-provider tokenizer. +const LEGACY_TOKENIZER_MODEL_OVERRIDES: Record = { + "anthropic:claude-fable-5": "anthropic/claude-opus-4.5", + "anthropic:claude-opus-4-8": "anthropic/claude-opus-4.5", +}; + +export const TOKENIZER_MODEL_OVERRIDES: Record = { + ...LEGACY_TOKENIZER_MODEL_OVERRIDES, + // Spread current models last so a returning id always wins over its legacy entry. + ...Object.fromEntries( + Object.values(KNOWN_MODELS) + .filter((model) => Boolean(model.tokenizerOverride)) + .map((model) => [model.id, model.tokenizerOverride!]) + ), +}; /** Tooltip-friendly abbreviation examples: show representative shortcuts */ export const MODEL_ABBREVIATION_EXAMPLES = (["opus", "sonnet"] as const).map((abbrev) => ({ From 116066c324e219854a789882726dcb172be6f5ad Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:13:57 +0000 Subject: [PATCH 3/3] fix: seed the legacy Fable 5 chain during the original seed pass for downgrade safety --- src/common/utils/ai/modelFallbacks.ts | 17 ++++++++++++- src/node/config.test.ts | 35 ++++++++++++++++++++++----- src/node/config.ts | 17 ++++++++++--- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/common/utils/ai/modelFallbacks.ts b/src/common/utils/ai/modelFallbacks.ts index bb025f9dd08..d80878c16e6 100644 --- a/src/common/utils/ai/modelFallbacks.ts +++ b/src/common/utils/ai/modelFallbacks.ts @@ -27,10 +27,25 @@ export const MODEL_FALLBACK_CHAIN_LIMIT = 3; export const DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { [KNOWN_MODELS.FABLE.id]: { models: [KNOWN_MODELS.OPUS.id] }, }; + +/** + * Legacy default chain for the pre-5.1 fable id, seeded alongside + * DEFAULT_MODEL_FALLBACKS whenever the original seed pass runs (fresh installs + * and configs that never completed it). Those configs mark + * defaultModelFallbacksSeeded, so a downgrade to a build whose FABLE is + * Fable 5 would skip its own seed; carrying this chain keeps the old build's + * refusal fallback intact. + */ +export const LEGACY_DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { + "anthropic:claude-fable-5": { models: [KNOWN_MODELS.OPUS.id] }, +}; // Deep-freeze: entries are spread by reference into live configs (fresh-install // defaults, seed merge). Accidental in-place mutation must crash fast instead // of silently corrupting the process-wide default. -for (const entry of Object.values(Object.freeze(DEFAULT_MODEL_FALLBACKS))) { +for (const entry of [ + ...Object.values(Object.freeze(DEFAULT_MODEL_FALLBACKS)), + ...Object.values(Object.freeze(LEGACY_DEFAULT_MODEL_FALLBACKS)), +]) { Object.freeze(entry); Object.freeze(entry.models); } diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 5481dab8819..dd152880f4f 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -1585,6 +1585,7 @@ describe("Config", () => { describe("default model fallbacks seeding", () => { const FABLE = KNOWN_MODELS.FABLE.id; + const LEGACY_FABLE = "anthropic:claude-fable-5"; const OPUS = KNOWN_MODELS.OPUS.id; const configFilePath = () => path.join(tempDir, "config.json"); @@ -1592,7 +1593,12 @@ describe("Config", () => { fs.writeFileSync(configFilePath(), JSON.stringify({ projects: [] })); const loaded = config.loadConfigOrDefault(); - expect(loaded.modelFallbacks).toEqual({ [FABLE]: { models: [OPUS] } }); + // The original seed pass also carries the legacy Fable 5 chain so a + // downgraded build (whose FABLE is Fable 5) still finds its default. + expect(loaded.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); // Seed is written back so the flag survives restarts even without saves. @@ -1601,7 +1607,10 @@ describe("Config", () => { modelFallbacks?: unknown; migrations?: { defaultModelFallbacksSeeded?: unknown }; }; - expect(raw.modelFallbacks).toEqual({ [FABLE]: { models: [OPUS] } }); + expect(raw.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); }); @@ -1623,7 +1632,8 @@ describe("Config", () => { it("seeds the Fable 5.1 chain once for configs seeded before the 5.1 promotion", async () => { // Pre-5.1 configs carry a chain only for the old source key // (anthropic:claude-fable-5); the promoted FABLE key must get its own - // one-time seed without touching the legacy chain. + // one-time seed without touching the legacy chain (the legacy default + // is only added while the original seed pass itself runs). fs.writeFileSync( configFilePath(), JSON.stringify({ @@ -1687,6 +1697,7 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.modelFallbacks).toEqual({ "anthropic:claude-opus-4-6": { models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1698,6 +1709,7 @@ describe("Config", () => { }; expect(raw.modelFallbacks).toEqual({ "anthropic:claude-opus-4-6": { models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [OPUS] }, [FABLE]: { models: [OPUS] }, }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1718,6 +1730,7 @@ describe("Config", () => { // the seed must treat it as configured and leave the user's chain alone. expect(config.loadConfigOrDefault().modelFallbacks).toEqual({ [FABLE]: { models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [OPUS] }, }); }); @@ -1735,10 +1748,15 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); // The entry sanitizes to nothing at runtime (no fallback fires), but it // is still user intent: the seed must not replace it with an enabled - // default chain, and the raw on-disk form must survive. - expect(loaded.modelFallbacks).toBeUndefined(); + // default chain, and the raw on-disk form must survive. Only the + // untouched legacy key gets seeded. + expect(loaded.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [OPUS] }, + }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); + // Raw file read before the async write-back flushes: the tombstone's + // on-disk form must survive untouched. const raw = JSON.parse(fs.readFileSync(configFilePath(), "utf-8")) as { modelFallbacks?: unknown; }; @@ -1779,12 +1797,14 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.modelFallbacks).toEqual({ [FABLE]: { enabled: false, models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [OPUS] }, }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); }); it("applies the defaults to fresh installs and locks the flag on first save", async () => { expect(config.loadConfigOrDefault().modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1794,7 +1814,10 @@ describe("Config", () => { modelFallbacks?: unknown; migrations?: { defaultModelFallbacksSeeded?: unknown }; }; - expect(raw.modelFallbacks).toEqual({ [FABLE]: { models: [OPUS] } }); + expect(raw.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); }); }); diff --git a/src/node/config.ts b/src/node/config.ts index e1dedc7093d..26ff2bcbd75 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -21,7 +21,11 @@ import type { ModelFallbacks, ProvidersConfig as CanonicalProvidersConfig, } from "@/common/config/schemas"; -import { DEFAULT_MODEL_FALLBACKS, sanitizeModelFallbacks } from "@/common/utils/ai/modelFallbacks"; +import { + DEFAULT_MODEL_FALLBACKS, + LEGACY_DEFAULT_MODEL_FALLBACKS, + sanitizeModelFallbacks, +} from "@/common/utils/ai/modelFallbacks"; import { DEFAULT_TASK_SETTINGS, normalizeTaskSettings } from "@/common/types/tasks"; import { normalizeUserPreferences } from "@/common/config/schemas/userPreferences"; import { SettingsBackupSchema } from "@/common/config/schemas/settingsBackup"; @@ -1384,8 +1388,15 @@ export class Config { const existingCanonicalKeys = new Set( Object.keys(rawFallbacks).map((key) => normalizeToCanonical(key).trim()) ); + // Completing the original seed pass claims defaultModelFallbacksSeeded, + // which downgraded builds trust for their own (pre-5.1) default keys; + // seed those legacy chains too so a downgrade keeps refusal fallback. + const seedDefaults = + migrationsBeforeSeed.defaultModelFallbacksSeeded !== true + ? { ...LEGACY_DEFAULT_MODEL_FALLBACKS, ...DEFAULT_MODEL_FALLBACKS } + : DEFAULT_MODEL_FALLBACKS; const missingDefaults = Object.fromEntries( - Object.entries(DEFAULT_MODEL_FALLBACKS).filter( + Object.entries(seedDefaults).filter( ([sourceModel]) => !existingCanonicalKeys.has(sourceModel) ) ); @@ -1580,7 +1591,7 @@ export class Config { // Fresh installs get the default refusal-fallback chains immediately; the // migration flag rides along so the first save locks in seed-once // semantics (later loads never re-apply the defaults). - modelFallbacks: { ...DEFAULT_MODEL_FALLBACKS }, + modelFallbacks: { ...LEGACY_DEFAULT_MODEL_FALLBACKS, ...DEFAULT_MODEL_FALLBACKS }, migrations: { defaultModelFallbacksSeeded: true, defaultModelFallbacksSeededFable51: true,