diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 419edfbe1a1..7741775e98a 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -13,10 +13,10 @@ 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` | | +| Sonnet 5.1 | anthropic:claude-sonnet-5-1 | `sonnet` | | | Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | | | GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | | | GPT-5.6 Terra | openai:gpt-5.6-terra | `terra` | | diff --git a/src/browser/utils/slashCommands/parser.test.ts b/src/browser/utils/slashCommands/parser.test.ts index d74c9fba1d5..a69c5dc611b 100644 --- a/src/browser/utils/slashCommands/parser.test.ts +++ b/src/browser/utils/slashCommands/parser.test.ts @@ -80,7 +80,7 @@ describe("commandParser", () => { }); it("should parse /model with full provider:model format", () => { - expectModelSet("/model anthropic:claude-sonnet-5", KNOWN_MODELS.SONNET.id); + expectModelSet("/model anthropic:claude-sonnet-5-1", KNOWN_MODELS.SONNET.id); }); it("should parse /compact -m with alias", () => { 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..a5e34815b20 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; @@ -69,18 +69,19 @@ const MODEL_DEFINITIONS = { // usage can run ~1.0-1.3x higher than this estimate (same situation as FABLE above). tokenizerOverride: "anthropic/claude-opus-4.5", }, - // Claude Sonnet 5 - released June 30, 2026. The most agentic Sonnet yet (native 1M context, - // 128K max output, adaptive thinking + effort including native xhigh). Standard pricing matches - // Sonnet 4.6 ($3/M in, $15/M out); introductory $2/$10 applies through Aug 31, 2026. API id - // `claude-sonnet-5`. The bare `sonnet` alias tracks the latest Sonnet tier. + // Claude Sonnet 5.1 - successor to Sonnet 5 (released June 30, 2026) with the same + // envelope (native 1M context, 128K max output, adaptive thinking + effort including + // native xhigh) at unchanged standard pricing ($3/M in, $15/M out). API id + // `claude-sonnet-5-1`; Sonnet 5 stays usable as the custom model string + // `anthropic:claude-sonnet-5`. The bare `sonnet` alias tracks the latest Sonnet tier. SONNET: { provider: "anthropic", - providerModelId: "claude-sonnet-5", + providerModelId: "claude-sonnet-5-1", aliases: ["sonnet"], warm: true, - // Sonnet 5 ships an updated tokenizer (same kind of change introduced with Opus 4.7) that - // isn't published upstream yet; reuse Sonnet 4.5 for approximate counting. Real usage can run - // ~1.0-1.35x higher than this estimate depending on content type. + // Sonnet 5/5.1 ship an updated tokenizer (same kind of change introduced with Opus 4.7) + // that isn't published upstream yet; reuse Sonnet 4.5 for approximate counting. Real usage + // can run ~1.0-1.35x higher than this estimate depending on content type. tokenizerOverride: "anthropic/claude-sonnet-4.5", }, HAIKU: { @@ -275,11 +276,24 @@ 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/SONNET 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", + "anthropic:claude-sonnet-5": "anthropic/claude-sonnet-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) => ({ diff --git a/src/common/utils/ai/modelDisplay.test.ts b/src/common/utils/ai/modelDisplay.test.ts index 0aa441b4403..4115a483d7c 100644 --- a/src/common/utils/ai/modelDisplay.test.ts +++ b/src/common/utils/ai/modelDisplay.test.ts @@ -19,6 +19,7 @@ describe("formatModelDisplayName", () => { test("formats Sonnet models", () => { expect(formatModelDisplayName("claude-sonnet-4-5")).toBe("Sonnet 4.5"); expect(formatModelDisplayName("claude-sonnet-4")).toBe("Sonnet 4"); + expect(formatModelDisplayName("claude-sonnet-5-1")).toBe("Sonnet 5.1"); }); test("formats Opus models", () => { @@ -27,6 +28,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..d80878c16e6 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 @@ -25,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/common/utils/ai/models.test.ts b/src/common/utils/ai/models.test.ts index f4eab11f218..9d250586648 100644 --- a/src/common/utils/ai/models.test.ts +++ b/src/common/utils/ai/models.test.ts @@ -159,20 +159,28 @@ describe("Anthropic 1M context classification", () => { expect(hasNative1MContext("anthropic:claude-sonnet-4-6")).toBe(true); }); - it("treats Sonnet 5 as a native 1M model", () => { + it("treats Sonnet 5 / Sonnet 5.1 as native 1M models", () => { expect(getAnthropic1MContextMode("anthropic:claude-sonnet-5")).toBe("native"); expect(getAnthropic1MContextMode("anthropic:claude-sonnet-5-20260630")).toBe("native"); expect(getAnthropic1MContextMode("mux-gateway:anthropic/claude-sonnet-5")).toBe("native"); expect(supports1MContext("anthropic:claude-sonnet-5")).toBe(false); expect(hasNative1MContext("anthropic:claude-sonnet-5")).toBe(true); + // The claude-sonnet-5 pattern only tolerates date suffixes, so 5.1 needs + // its own pattern (same trap as claude-fable-5-1). + expect(getAnthropic1MContextMode("anthropic:claude-sonnet-5-1")).toBe("native"); + expect(getAnthropic1MContextMode("mux-gateway:anthropic/claude-sonnet-5-1")).toBe("native"); + expect(supports1MContext("anthropic:claude-sonnet-5-1")).toBe(false); + expect(hasNative1MContext("anthropic:claude-sonnet-5-1")).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..a03dfd2d1f5 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -179,12 +179,14 @@ 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"), new RegExp(`^claude-opus-4-7${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-opus-4-6${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-sonnet-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), + new RegExp(`^claude-sonnet-5-1${OPTIONAL_VERSION_SUFFIX}$`, "i"), new RegExp(`^claude-sonnet-4-6${OPTIONAL_VERSION_SUFFIX}$`, "i"), ]; const ANTHROPIC_BETA_1M_PATTERNS = [ diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 8467dbe0ca0..348c1bb0c76 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -139,7 +139,14 @@ describe("buildProviderOptions - Anthropic", () => { // Native-xhigh models (Opus 4.7+ / Sonnet 5+): xhigh is a distinct native // effort and adaptive thinking requires `display: "summarized"` to return // thinking content. - for (const model of ["claude-opus-4-7", "claude-opus-5", "claude-sonnet-5"] as const) { + // Sonnet 5.1 rides the same Sonnet 5+ wildcard matcher; unlike Mythos-class + // models it still sends `thinking: { type: "disabled" }` when off. + for (const model of [ + "claude-opus-4-7", + "claude-opus-5", + "claude-sonnet-5", + "claude-sonnet-5-1", + ] as const) { describe(`${model} (native xhigh effort + summarized display)`, () => { for (const { thinking, expectedThinking, effort } of [ { @@ -180,6 +187,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 +202,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" }, + }); }); }); @@ -2047,6 +2063,7 @@ describe("buildRequestHeaders", () => { test("does not emit any Xum-internal effort header for native-xhigh models", () => { expect(buildRequestHeaders("anthropic:claude-opus-4-7")).toBeUndefined(); expect(buildRequestHeaders("anthropic:claude-sonnet-5")).toBeUndefined(); + expect(buildRequestHeaders("anthropic:claude-sonnet-5-1")).toBeUndefined(); }); describe("openaiDirectProviderOptionsAvailable", () => { diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index baa3862272b..5995b1d3777 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"); }); @@ -524,7 +532,7 @@ describe("getThinkingPolicyForModel", () => { expect(getDefaultMinimumThinkingLevel("openai:team-sol")).toBe("off"); }); - test("returns all 6 levels for Sonnet 5 (native xhigh)", () => { + test("returns all 6 levels for Sonnet 5 / Sonnet 5.1 (native xhigh)", () => { // Sonnet 5 introduced the native xhigh effort level for the Sonnet tier, so it exposes // all 6 levels (unlike Sonnet 4.6, which maps xhigh -> "max" and stops at 5). expect(getThinkingPolicyForModel("anthropic:claude-sonnet-5")).toEqual([ @@ -535,6 +543,17 @@ describe("getThinkingPolicyForModel", () => { "xhigh", "max", ]); + // Sonnet 5.1 rides the same Sonnet 5+ wildcard matcher. Unlike Mythos-class + // models, the Sonnet tier keeps "off" (disabled thinking stays accepted). + expect(getThinkingPolicyForModel("anthropic:claude-sonnet-5-1")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(enforceThinkingPolicy("anthropic:claude-sonnet-5-1", "off")).toBe("off"); expect(getThinkingPolicyForModel("anthropic:claude-sonnet-5-20260630")).toEqual([ "off", "low", diff --git a/src/common/utils/tokens/models-extra.ts b/src/common/utils/tokens/models-extra.ts index e28a1bc340b..60d46153766 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 @@ -257,6 +276,26 @@ export const modelsExtra: Record = { supports_response_schema: true, }, + // Claude Sonnet 5.1 - successor to Sonnet 5 with the same envelope and standard + // pricing: $3/M input, $15/M output, cache write 1.25x input / cache read 0.1x + // input, native 1M context, 128K max output, adaptive thinking + effort (incl. + // native xhigh). + "claude-sonnet-5-1": { + max_input_tokens: 1000000, + max_output_tokens: 128000, + input_cost_per_token: 0.000003, // $3 per million input tokens + output_cost_per_token: 0.000015, // $15 per million output tokens + cache_creation_input_token_cost: 0.00000375, // $3.75 per million tokens + cache_read_input_token_cost: 0.0000003, // $0.30 per million tokens + litellm_provider: "anthropic", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_pdf_input: true, + supports_reasoning: true, + supports_response_schema: true, + }, + // Claude Sonnet 5 - Released June 30, 2026 // Native 1M context. Standard pricing $3/M input, $15/M output (same as Sonnet 4.6). // Introductory pricing of $2/$10 per MTok applies through Aug 31, 2026, but we list the diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts index fd5fb8fcf77..cb6413a04c7 100644 --- a/src/common/utils/tools/tools.test.ts +++ b/src/common/utils/tools/tools.test.ts @@ -72,6 +72,8 @@ 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-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..dd152880f4f 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": { @@ -1581,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"); @@ -1588,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. @@ -1597,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); }); @@ -1606,13 +1619,70 @@ 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 (the legacy default + // is only added while the original seed pass itself runs). + 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(), @@ -1627,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] }, }); @@ -1638,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); @@ -1649,7 +1721,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"] }, }, }) ); @@ -1658,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] }, }); }); @@ -1675,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; }; @@ -1719,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] }, }); @@ -1734,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); }); }); @@ -2687,9 +2770,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..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"; @@ -1356,12 +1360,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 @@ -1376,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) ) ); @@ -1391,6 +1410,7 @@ export class Config { parsed.migrations = { ...migrationsBeforeSeed, defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, }; configModified = true; } @@ -1571,9 +1591,10 @@ 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, persistentSubagentsDefaulted: true, }, }; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 891b5cc5a1e..2f6a799921c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3850,10 +3850,10 @@ 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` | |", + "| Sonnet 5.1 | anthropic:claude-sonnet-5-1 | `sonnet` | |", "| Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | |", "| GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | |", "| GPT-5.6 Terra | openai:gpt-5.6-terra | `terra` | |", diff --git a/src/node/services/mock/mockAiRouter.ts b/src/node/services/mock/mockAiRouter.ts index 02aa1f8ddf3..7e021899f14 100644 --- a/src/node/services/mock/mockAiRouter.ts +++ b/src/node/services/mock/mockAiRouter.ts @@ -396,7 +396,7 @@ function buildReviewShowDocReply(): MockAiRouterReply { function buildModelStatusReply(): MockAiRouterReply { return { - assistantText: "Claude Sonnet 5 is now responding with standard reasoning capacity.", + assistantText: "Claude Sonnet 5.1 is now responding with standard reasoning capacity.", }; } diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index 81a5b3df74f..73a8de086e7 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -1649,7 +1649,7 @@ describe("ProviderService.setConfig", () => { const providersConfig = config.loadProvidersConfig(); expect(providersConfig?.["mux-gateway"]?.models).toEqual([ - "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-5-1", "anthropic/claude-opus-5", "openai/gpt-5.5", ]); diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index c2528c9a5f6..67ab1640c46 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -1514,7 +1514,7 @@ export class ProviderService { const existingModels = normalizeProviderModelEntries(providerConfig.models); if (existingModels.length === 0) { providerConfig.models = [ - "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-5-1", "anthropic/claude-opus-5", "openai/gpt-5.5", ]; diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index f32ead99e13..8ec60118ec5 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -477,7 +477,7 @@ describe("task tool", () => { expect(create).toHaveBeenCalledTimes(1); const createArgs = create.mock.calls[0]?.[0]; - expect(createArgs?.modelString).toBe("anthropic:claude-sonnet-5"); + expect(createArgs?.modelString).toBe("anthropic:claude-sonnet-5-1"); expect(createArgs?.thinkingLevel).toBe("high"); // Parent runtime hint is still forwarded so unspecified fields keep inheriting. expect(createArgs?.parentRuntimeAiSettings).toEqual({ 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", }), ]); diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index 38b13579f0d..10684180ce6 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -80,18 +80,18 @@ test.describe("slash command flows", () => { await expect(modeToggles.getByText("Opus 5", { exact: true })).toBeVisible(); await ui.chat.sendMessage("/model sonnet"); - await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-sonnet-5"); + await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-sonnet-5-1"); // Model is displayed as formatted name - await expect(modeToggles.getByText("Sonnet 5", { exact: true })).toBeVisible(); + await expect(modeToggles.getByText("Sonnet 5.1", { exact: true })).toBeVisible(); const timeline = await ui.chat.captureStreamTimeline(async () => { await ui.chat.sendMessage(MOCK_SLASH_COMMAND_PROMPTS.MODEL_STATUS); }); const streamStart = timeline.events.find((event) => event.type === "stream-start"); - expect(streamStart?.model).toBe("anthropic:claude-sonnet-5"); + expect(streamStart?.model).toBe("anthropic:claude-sonnet-5-1"); await ui.chat.expectTranscriptContains( - "Claude Sonnet 5 is now responding with standard reasoning capacity." + "Claude Sonnet 5.1 is now responding with standard reasoning capacity." ); }); }); diff --git a/tests/ipc/streaming/sendMessage.reasoning.test.ts b/tests/ipc/streaming/sendMessage.reasoning.test.ts index 2e150d55f66..a921ae915d0 100644 --- a/tests/ipc/streaming/sendMessage.reasoning.test.ts +++ b/tests/ipc/streaming/sendMessage.reasoning.test.ts @@ -15,7 +15,17 @@ import { withSharedWorkspace, configureTestRetries, } from "../sendMessageTestHelpers"; -import { KNOWN_MODELS } from "../../../src/common/constants/knownModels"; +// These tests hit the live Anthropic API, and KNOWN_MODELS.SONNET.id now points at +// claude-sonnet-5-1, which does not exist at the API until Anthropic announces it +// (this PR is prepared ahead of the drop). Pin the last live Sonnet id so the tests +// keep exercising real Sonnet-tier reasoning behavior in the meantime. +// +// TODO(sonnet-5-1-release): switch back to KNOWN_MODELS.SONNET.id as part of the +// gated merge, once the id is live — otherwise the promoted production path +// (adaptive thinking + effort + summarized display on claude-sonnet-5-1) is never +// exercised by live integration coverage. This is an explicit release-day item in +// the PR's merge gate. +const LIVE_SONNET_MODEL = "anthropic:claude-sonnet-5"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -36,7 +46,7 @@ describeIntegration("Anthropic reasoning parameter tests", () => { async () => { await withSharedWorkspace("anthropic", async ({ env, workspaceId, collector }) => { const result = await sendMessage(env, workspaceId, "What is 2+2? Answer in one word.", { - model: KNOWN_MODELS.SONNET.id, + model: LIVE_SONNET_MODEL, thinkingLevel: "low", }); expect(result.success).toBe(true); @@ -56,7 +66,7 @@ describeIntegration("Anthropic reasoning parameter tests", () => { async () => { await withSharedWorkspace("anthropic", async ({ env, workspaceId, collector }) => { const result = await sendMessage(env, workspaceId, "What is 4+4? Answer in one word.", { - model: KNOWN_MODELS.SONNET.id, + model: LIVE_SONNET_MODEL, thinkingLevel: "low", }); expect(result.success).toBe(true); @@ -76,7 +86,7 @@ describeIntegration("Anthropic reasoning parameter tests", () => { async () => { await withSharedWorkspace("anthropic", async ({ env, workspaceId, collector }) => { const result = await sendMessage(env, workspaceId, "Explain briefly why 2+2=4", { - model: KNOWN_MODELS.SONNET.id, + model: LIVE_SONNET_MODEL, thinkingLevel: "medium", }); expect(result.success).toBe(true);