diff --git a/.github/workflows/auto-cleanup-fixup.yml b/.github/workflows/auto-cleanup-fixup.yml index ef85fb9fd5..815c2387cd 100644 --- a/.github/workflows/auto-cleanup-fixup.yml +++ b/.github/workflows/auto-cleanup-fixup.yml @@ -118,6 +118,6 @@ jobs: FAILED_RUN_ID: ${{ github.event.workflow_run.id }} run: | bunx mux@next run \ - --model anthropic:claude-opus-5 \ + --model anthropic:claude-opus-5-1 \ --thinking xhigh \ < .github/prompts/auto-cleanup-fixup.md diff --git a/.github/workflows/auto-cleanup.yml b/.github/workflows/auto-cleanup.yml index 62d1eb9149..f765c28f13 100644 --- a/.github/workflows/auto-cleanup.yml +++ b/.github/workflows/auto-cleanup.yml @@ -71,6 +71,6 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | bunx @coder/xum@next run \ - --model anthropic:claude-opus-5 \ + --model anthropic:claude-opus-5-1 \ --thinking xhigh \ < .github/prompts/auto-cleanup.md diff --git a/.github/workflows/nightly-terminal-bench.yml b/.github/workflows/nightly-terminal-bench.yml index d6d7975721..ed8749eb50 100644 --- a/.github/workflows/nightly-terminal-bench.yml +++ b/.github/workflows/nightly-terminal-bench.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: models: - description: 'Models to test (comma-separated, or "all" for opus-5 + gpt-5.6-sol + google/gemini-3-pro-preview + google/gemini-3-flash-preview + google/gemini-3.7-flash)' + description: 'Models to test (comma-separated, or "all" for opus-5-1 + gpt-5.6-sol + google/gemini-3-pro-preview + google/gemini-3-flash-preview + google/gemini-3.7-flash)' required: false default: "all" type: string @@ -129,7 +129,7 @@ jobs: INPUT_MODELS: ${{ inputs.models }} run: | if [ "$INPUT_MODELS" = "all" ] || [ -z "$INPUT_MODELS" ]; then - echo 'models=["anthropic/claude-opus-5","openai/gpt-5.6-sol","google/gemini-3-pro-preview","google/gemini-3-flash-preview","google/gemini-3.7-flash"]' >> "$GITHUB_OUTPUT" + echo 'models=["anthropic/claude-opus-5-1","openai/gpt-5.6-sol","google/gemini-3-pro-preview","google/gemini-3-flash-preview","google/gemini-3.7-flash"]' >> "$GITHUB_OUTPUT" else # Convert comma-separated to JSON array models_json=$(echo "$INPUT_MODELS" | jq -R -s -c 'split(",") | map(gsub("^\\s+|\\s+$"; ""))') diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml index fcd4ff7301..606596be57 100644 --- a/.github/workflows/terminal-bench.yml +++ b/.github/workflows/terminal-bench.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: model_name: - description: "Model to use (e.g., anthropic/claude-opus-5, openai/gpt-5.6-sol)" + description: "Model to use (e.g., anthropic/claude-opus-5-1, openai/gpt-5.6-sol)" required: false type: string dataset: @@ -98,7 +98,7 @@ on: required: false type: string model_name: - description: "Model to use (e.g., anthropic/claude-opus-5, openai/gpt-5.6-sol)" + description: "Model to use (e.g., anthropic/claude-opus-5-1, openai/gpt-5.6-sol)" required: false type: string mux_run_args: diff --git a/benchmarks/terminal_bench/prepare_leaderboard_submission.py b/benchmarks/terminal_bench/prepare_leaderboard_submission.py index 4b94e293b8..5b7cebe89a 100755 --- a/benchmarks/terminal_bench/prepare_leaderboard_submission.py +++ b/benchmarks/terminal_bench/prepare_leaderboard_submission.py @@ -123,6 +123,13 @@ "model_org_display_name": "Anthropic", "folder_name": "Claude-Opus-5", }, + "anthropic/claude-opus-5-1": { + "model_name": "claude-opus-5-1", + "model_provider": "anthropic", + "model_display_name": "Claude Opus 5.1", + "model_org_display_name": "Anthropic", + "folder_name": "Claude-Opus-5.1", + }, # Keep historical GPT metadata alongside the current GPT-5.6 Sol bench target # so mixed or older artifact sets still map to the canonical leaderboard names. "openai/gpt-5.2": { diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 419edfbe1a..c1931d98f9 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -13,9 +13,9 @@ 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` | ✓ | +| Opus 5.1 | anthropic:claude-opus-5-1 | `opus` | ✓ | | Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | | | Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | | | GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | | diff --git a/docs/guides/github-actions.mdx b/docs/guides/github-actions.mdx index f8f0ac4e56..19c0a61704 100644 --- a/docs/guides/github-actions.mdx +++ b/docs/guides/github-actions.mdx @@ -116,7 +116,7 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | bunx @coder/xum@next run \ - --model anthropic:claude-opus-5 \ + --model anthropic:claude-opus-5-1 \ --thinking xhigh \ < .github/prompts/auto-cleanup.md ``` diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 7bf421971c..13222c00cb 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1360,7 +1360,7 @@ describe("useCreationWorkspace", () => { }); expect(onWorkspaceCreated.mock.calls[0][1]).toEqual({ autoNavigate: true, - pendingStreamModel: "anthropic:claude-opus-5", + pendingStreamModel: "anthropic:claude-opus-5-1", markPendingInitialSend: false, }); }); @@ -1838,7 +1838,7 @@ describe("useCreationWorkspace", () => { expect(onWorkspaceCreated.mock.calls.length).toBe(1); expect(onWorkspaceCreated.mock.calls[0][1]).toEqual({ autoNavigate: true, - pendingStreamModel: "anthropic:claude-opus-5", + pendingStreamModel: "anthropic:claude-opus-5-1", markPendingInitialSend: true, }); }); diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 194fd35352..b9436c82ef 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -171,7 +171,7 @@ describe("xum CLI", () => { test("shows default model as opus", async () => { const result = await runCli(["run", "--help"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("anthropic:claude-opus-5"); + expect(result.stdout).toContain("anthropic:claude-opus-5-1"); }); test("--service-tier has no default auto", async () => { diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 4a26dc8145..d8fd02b978 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -117,6 +117,19 @@ 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 chain-target migration after the opus alias moved to Opus 5.1: + * chains still exactly equal to the superseded shipped default + * (Fable 5.1 → Opus 5) move to the current default (Fable 5.1 → Opus 5.1); + * any user-edited chain is left untouched. + */ + defaultModelFallbacksSeededOpus51: 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.test.ts b/src/common/constants/knownModels.test.ts index 0b4e15c0b8..8daa0e9d1f 100644 --- a/src/common/constants/knownModels.test.ts +++ b/src/common/constants/knownModels.test.ts @@ -3,7 +3,11 @@ */ import { describe, test, expect } from "@jest/globals"; -import { KNOWN_MODELS, MODEL_ABBREVIATIONS } from "@/common/constants/knownModels"; +import { + KNOWN_MODELS, + MODEL_ABBREVIATIONS, + TOKENIZER_MODEL_OVERRIDES, +} from "@/common/constants/knownModels"; import modelsJson from "@/common/utils/tokens/models.json"; import { findMissingKnownModels } from "@/common/utils/tokens/updateModelsData"; @@ -19,6 +23,16 @@ describe("Known Models Integration", () => { } }); + test("opus alias tracks Opus 5.1 and retired ids keep tokenizer overrides", () => { + expect(MODEL_ABBREVIATIONS.opus).toBe("anthropic:claude-opus-5-1"); + expect(KNOWN_MODELS.OPUS.id).toBe("anthropic:claude-opus-5-1"); + // Exact-id lookup for retired-but-documented custom model strings must keep + // resolving to the Opus 4.5 approximation instead of falling back (with a + // warning) to the generic per-provider tokenizer. + expect(TOKENIZER_MODEL_OVERRIDES["anthropic:claude-opus-5"]).toBe("anthropic/claude-opus-4.5"); + expect(TOKENIZER_MODEL_OVERRIDES[KNOWN_MODELS.OPUS.id]).toBe("anthropic/claude-opus-4.5"); + }); + test("gemini-flash resolves to the stable Gemini 3.7 Flash model", () => { expect(MODEL_ABBREVIATIONS["gemini-flash"]).toBe("google:gemini-3.7-flash"); }); diff --git a/src/common/constants/knownModels.ts b/src/common/constants/knownModels.ts index 7fb89c5666..9ae01afeb7 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; @@ -56,15 +56,15 @@ const MODEL_DEFINITIONS = { // approximate counting; real usage can run ~1.0-1.3x higher. tokenizerOverride: "anthropic/claude-opus-4.5", }, - // Claude Opus 5 - released July 24, 2026. Successor to Opus 4.8 at the same pricing - // ($5/M input, $25/M output). API id `claude-opus-5`; Opus 4.8 stays usable as the - // custom model string `anthropic:claude-opus-4-8`. + // Claude Opus 5.1 - successor to Opus 5 (released July 24, 2026) at unchanged pricing + // ($5/M input, $25/M output). API id `claude-opus-5-1`; Opus 5 stays usable as the + // custom model string `anthropic:claude-opus-5`. OPUS: { provider: "anthropic", - providerModelId: "claude-opus-5", + providerModelId: "claude-opus-5-1", aliases: ["opus"], warm: true, - // Opus 5 uses the newer Opus 4.7+ tokenizer (~30% more tokens for the same text), + // Opus 5.1 uses the newer Opus 4.7+ tokenizer (~30% more tokens for the same text), // which isn't published upstream; reuse Opus 4.5 for approximate counting. Real // usage can run ~1.0-1.3x higher than this estimate (same situation as FABLE above). tokenizerOverride: "anthropic/claude-opus-4.5", @@ -275,11 +275,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 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-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) => ({ diff --git a/src/common/utils/ai/modelDisplay.test.ts b/src/common/utils/ai/modelDisplay.test.ts index 0aa441b440..62cafa4add 100644 --- a/src/common/utils/ai/modelDisplay.test.ts +++ b/src/common/utils/ai/modelDisplay.test.ts @@ -10,6 +10,7 @@ describe("formatCompactModelDisplayName", () => { test("falls back to the full display name when no shorter label is clearer", () => { expect(formatCompactModelDisplayName("claude-opus-5")).toBe("Opus 5"); + expect(formatCompactModelDisplayName("claude-opus-5-1")).toBe("Opus 5.1"); expect(formatCompactModelDisplayName("gpt-5.3-codex-spark")).toBe("Spark 5.3"); }); }); @@ -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 428aaae680..a55e6b6343 100644 --- a/src/common/utils/ai/modelFallbacks.ts +++ b/src/common/utils/ai/modelFallbacks.ts @@ -16,7 +16,11 @@ 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, + * and the one-shot defaultModelFallbacksSeededOpus51 target migration for the + * Opus 5.1 promotion, see SUPERSEDED_DEFAULT_MODEL_FALLBACKS) + * — 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 +29,43 @@ 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 = { + // Target pinned to the literal Opus 5 id (not KNOWN_MODELS.OPUS.id): this + // chain exists for downgraded pre-5.1 builds, whose own shipped default was + // Fable 5 → Opus 5. Pinning keeps the seeded bytes identical no matter which + // build ran the original seed pass. + "anthropic:claude-fable-5": { models: ["anthropic:claude-opus-5"] }, +}; + +/** + * Shipped defaults superseded by the Opus 5.1 promotion, keyed like + * DEFAULT_MODEL_FALLBACKS. Guarded by migrations.defaultModelFallbacksSeededOpus51, + * config load rewrites a chain to the current default exactly once IF the stored + * entry still deep-equals the superseded default (same single-model chain, no + * enabled/triggers customization). Any deviation is user intent and is never + * touched; the legacy `anthropic:claude-fable-5` chain is deliberately not + * migrated so downgraded builds keep their expected bytes on disk. + */ +export const SUPERSEDED_DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { + [KNOWN_MODELS.FABLE.id]: { models: ["anthropic:claude-opus-5"] }, +}; // 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.values(Object.freeze(SUPERSEDED_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 f4eab11f21..2c78c40ee1 100644 --- a/src/common/utils/ai/models.test.ts +++ b/src/common/utils/ai/models.test.ts @@ -143,9 +143,15 @@ describe("Anthropic 1M context classification", () => { expect(hasNative1MContext("anthropic:claude-sonnet-4-5")).toBe(false); }); - it("treats Opus 4.6 to 4.8, Opus 5, and Sonnet 4.6 as native 1M models", () => { + it("treats Opus 4.6 to 4.8, Opus 5, Opus 5.1, and Sonnet 4.6 as native 1M models", () => { expect(getAnthropic1MContextMode("anthropic:claude-opus-5")).toBe("native"); expect(getAnthropic1MContextMode("anthropic:claude-opus-5-20260724")).toBe("native"); + // The claude-opus-5 pattern only tolerates date suffixes, so the 5.1 id + // needs (and has) its own pattern. + expect(getAnthropic1MContextMode("anthropic:claude-opus-5-1")).toBe("native"); + expect(getAnthropic1MContextMode("mux-gateway:anthropic/claude-opus-5-1")).toBe("native"); + expect(hasNative1MContext("anthropic:claude-opus-5-1")).toBe(true); + expect(supports1MContext("anthropic:claude-opus-5-1")).toBe(false); expect(getAnthropic1MContextMode("anthropic:claude-opus-4-8")).toBe("native"); expect(getAnthropic1MContextMode("anthropic:claude-opus-4-8-20260528")).toBe("native"); expect(getAnthropic1MContextMode("anthropic:claude-opus-4-6")).toBe("native"); @@ -167,12 +173,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 f4b4d614ea..33f851e2c6 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -179,8 +179,10 @@ 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-5-1${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"), diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 8467dbe0ca..95a00d320b 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -139,7 +139,12 @@ 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) { + for (const model of [ + "claude-opus-4-7", + "claude-opus-5", + "claude-opus-5-1", + "claude-sonnet-5", + ] as const) { describe(`${model} (native xhigh effort + summarized display)`, () => { for (const { thinking, expectedThinking, effort } of [ { @@ -180,6 +185,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 +200,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 baa3862272..50b3095e9c 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -423,6 +423,16 @@ describe("getThinkingPolicyForModel", () => { "xhigh", "max", ]); + // Opus 5.1 rides the same Opus 5+ wildcard: full 6-level ladder, and + // (unlike Mythos-class models) "off" stays available. + expect(getThinkingPolicyForModel("anthropic:claude-opus-5-1")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); expect(getThinkingPolicyForModel("anthropic:claude-opus-5-0")).toEqual([ "off", "low", @@ -443,6 +453,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 +472,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 e28a1bc340..6780539514 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 @@ -175,6 +194,25 @@ export const modelsExtra: Record = { supports_response_schema: true, }, + // Claude Opus 5.1 - successor to Opus 5 at the same pricing/shape: $5/M input, + // $25/M output, cache write 1.25x input / cache read 0.1x input, native 1M + // context, 128K max output, full effort ladder with native xhigh and max. + "claude-opus-5-1": { + max_input_tokens: 1000000, + max_output_tokens: 128000, + input_cost_per_token: 0.000005, // $5 per million input tokens + output_cost_per_token: 0.000025, // $25 per million output tokens + cache_creation_input_token_cost: 0.00000625, // $6.25 per million tokens (1.25× input) + cache_read_input_token_cost: 0.0000005, // $0.50 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 Opus 5 - Released July 24, 2026 // Same pricing/shape as Opus 4.8: $5/M input, $25/M output, native 1M context // (both default and maximum), 128K max output, full effort ladder with native diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts index fd5fb8fcf7..e51d572633 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-opus-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 484f9fb306..8cd1b6a8bc 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -113,6 +113,8 @@ describe("Config", () => { taskSettings: { preserveSubagentsUntilArchive: true }, migrations: { defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + defaultModelFallbacksSeededOpus51: true, persistentSubagentsDefaulted: true, }, }) @@ -1551,7 +1553,11 @@ describe("Config", () => { JSON.stringify({ projects: [], // Keep this test focused on normalization, not default seeding. - migrations: { defaultModelFallbacksSeeded: true }, + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + defaultModelFallbacksSeededOpus51: true, + }, modelFallbacks: { // Gateway-prefixed key + non-string chain entries + unknown trigger. "openrouter:anthropic/claude-opus-4-6": { @@ -1581,14 +1587,23 @@ 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; + // The legacy Fable 5 chain targets the literal Opus 5 id (a downgraded + // build's own shipped default), NOT the moving KNOWN_MODELS.OPUS id. + const LEGACY_OPUS = "anthropic:claude-opus-5"; const configFilePath = () => path.join(tempDir, "config.json"); it("seeds the default chain once on first load and persists the migration flag", async () => { 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: [LEGACY_OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); // Seed is written back so the flag survives restarts even without saves. @@ -1597,7 +1612,10 @@ describe("Config", () => { modelFallbacks?: unknown; migrations?: { defaultModelFallbacksSeeded?: unknown }; }; - expect(raw.modelFallbacks).toEqual({ [FABLE]: { models: [OPUS] } }); + expect(raw.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); }); @@ -1606,13 +1624,208 @@ 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("migrates the shipped Fable 5.1 → Opus 5 default to Opus 5.1 exactly once", async () => { + // A config seeded before the Opus 5.1 promotion holds the superseded + // default target. The one-shot target migration moves it to the current + // default while leaving the legacy Fable 5 chain byte-identical. + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, + modelFallbacks: { + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, + [FABLE]: { models: [LEGACY_OPUS] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, + [FABLE]: { models: [OPUS] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + + await flushConfigEdits(); + const raw = JSON.parse(fs.readFileSync(configFilePath(), "utf-8")) as { + modelFallbacks?: unknown; + migrations?: { defaultModelFallbacksSeededOpus51?: unknown }; + }; + expect(raw.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, + [FABLE]: { models: [OPUS] }, + }); + expect(raw.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + }); + + it("leaves customized chains untouched during the Opus 5.1 target migration", () => { + // Same models as the superseded default, but the user added a field: + // the entry no longer deep-equals the shipped default, so it is intent. + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, + modelFallbacks: { + [FABLE]: { enabled: false, models: [LEGACY_OPUS] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + [FABLE]: { enabled: false, models: [LEGACY_OPUS] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + }); + + it("does not resurrect a deleted chain during the Opus 5.1 target migration", () => { + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toBeUndefined(); + expect(loaded.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + }); + + it("does not migrate a gateway-keyed chain during the Opus 5.1 target migration", () => { + // We only ever seeded the canonical key; a gateway-prefixed key with the + // old target was hand-written by the user, so its chain must keep the + // old target even though the key canonicalizes to the FABLE source. + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, + modelFallbacks: { + "openrouter:anthropic/claude-fable-5-1": { models: [LEGACY_OPUS] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + [FABLE]: { models: [LEGACY_OPUS] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + + // Raw file read before the async write-back flushes: the migration pass + // itself must not have rewritten the user's entry. + const raw = JSON.parse(fs.readFileSync(configFilePath(), "utf-8")) as { + modelFallbacks?: unknown; + }; + expect(raw.modelFallbacks).toEqual({ + "openrouter:anthropic/claude-fable-5-1": { models: [LEGACY_OPUS] }, + }); + }); + + it("re-running the Opus 5.1 target migration is a no-op once migrated", () => { + // A downgrade to a build predating the flag could strip it; re-running + // the migration must not touch a chain already on the current default + // (or anything the user changed since). + fs.writeFileSync( + configFilePath(), + JSON.stringify({ + projects: [], + migrations: { + defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }, + modelFallbacks: { + [FABLE]: { models: [OPUS] }, + }, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.modelFallbacks).toEqual({ + [FABLE]: { models: [OPUS] }, + }); + expect(loaded.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); + }); + it("merges the seeded default with pre-existing chains for other source models", async () => { fs.writeFileSync( configFilePath(), @@ -1627,6 +1840,7 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.modelFallbacks).toEqual({ "anthropic:claude-opus-4-6": { models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1638,6 +1852,7 @@ describe("Config", () => { }; expect(raw.modelFallbacks).toEqual({ "anthropic:claude-opus-4-6": { models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1649,7 +1864,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 +1873,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: [LEGACY_OPUS] }, }); }); @@ -1675,10 +1891,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: [LEGACY_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 +1940,14 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.modelFallbacks).toEqual({ [FABLE]: { enabled: false, models: ["openai:gpt-5.5"] }, + [LEGACY_FABLE]: { models: [LEGACY_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: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1732,10 +1955,19 @@ describe("Config", () => { const raw = JSON.parse(fs.readFileSync(configFilePath(), "utf-8")) as { modelFallbacks?: unknown; - migrations?: { defaultModelFallbacksSeeded?: unknown }; + migrations?: { + defaultModelFallbacksSeeded?: unknown; + defaultModelFallbacksSeededOpus51?: unknown; + }; }; - expect(raw.modelFallbacks).toEqual({ [FABLE]: { models: [OPUS] } }); + expect(raw.modelFallbacks).toEqual({ + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, + [FABLE]: { models: [OPUS] }, + }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); + // Fresh installs already carry the current default target; the Opus 5.1 + // migration flag rides along so the target migration never re-runs. + expect(raw.migrations?.defaultModelFallbacksSeededOpus51).toBe(true); }); }); @@ -2687,9 +2919,13 @@ 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, + defaultModelFallbacksSeededOpus51: true, + }, }) ); diff --git a/src/node/config.ts b/src/node/config.ts index 596c1388a3..e32213f5e0 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -21,7 +21,12 @@ 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, + SUPERSEDED_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"; @@ -558,6 +563,32 @@ function normalizeConfigMigrations(value: unknown): AppConfigMigrations { return migrations; } +/** + * True when a raw on-disk fallback entry deep-equals a shipped default chain: + * an object whose only key is `models`, holding exactly the default's model + * strings in order. Used by the one-time Opus 5.1 target migration to tell + * "still the shipped default" apart from user-edited entries. + */ +function isExactDefaultFallbackChain( + entry: unknown, + defaultEntry: ModelFallbacks[string] +): boolean { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + return false; + } + const record = entry as Record; + const keys = Object.keys(record); + if (keys.length !== 1 || keys[0] !== "models") { + return false; + } + const models = record.models; + return ( + Array.isArray(models) && + models.length === defaultEntry.models.length && + defaultEntry.models.every((model, index) => models[index] === model) + ); +} + function parseOptionalPort(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { return undefined; @@ -1356,12 +1387,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 +1415,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 +1437,46 @@ export class Config { parsed.migrations = { ...migrationsBeforeSeed, defaultModelFallbacksSeeded: true, + defaultModelFallbacksSeededFable51: true, + }; + configModified = true; + } + + // One-time chain-TARGET migration for the Opus 5.1 promotion. Unlike + // the key-gap seeds above (new source keys), promoting OPUS only moved + // the target of an existing default chain (Fable 5.1 → Opus 5 became + // Fable 5.1 → Opus 5.1), and already-seeded configs would keep the + // stale target forever. A chain that still deep-equals the superseded + // shipped default is provably untouched (or indistinguishable from it), + // so it moves to the current default exactly once; anything else — + // edited chains, enabled/triggers customization, deletions, and + // non-canonically keyed entries — is user intent and is never touched. + // Runs after the seed pass so freshly seeded chains (already on the + // new target) are no-ops, and stays idempotent if an old build strips + // the flag: migrated or edited chains no longer match the superseded + // shape. + const migrationsBeforeTargetMigration = normalizeConfigMigrations(parsed.migrations); + if (migrationsBeforeTargetMigration.defaultModelFallbacksSeededOpus51 !== true) { + const rawFallbacks = + typeof parsed.modelFallbacks === "object" && + parsed.modelFallbacks !== null && + !Array.isArray(parsed.modelFallbacks) + ? (parsed.modelFallbacks as Record) + : {}; + const migratedDefaults = Object.fromEntries( + Object.entries(SUPERSEDED_DEFAULT_MODEL_FALLBACKS) + .filter(([sourceModel, superseded]) => + isExactDefaultFallbackChain(rawFallbacks[sourceModel], superseded) + ) + .map(([sourceModel]) => [sourceModel, DEFAULT_MODEL_FALLBACKS[sourceModel]]) + ); + if (Object.keys(migratedDefaults).length > 0) { + const rawParsed: Record = parsed; + rawParsed.modelFallbacks = { ...rawFallbacks, ...migratedDefaults }; + } + parsed.migrations = { + ...migrationsBeforeTargetMigration, + defaultModelFallbacksSeededOpus51: true, }; configModified = true; } @@ -1571,9 +1657,11 @@ 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, + defaultModelFallbacksSeededOpus51: true, persistentSubagentsDefaulted: true, }, }; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 891b5cc5a1..9b68de03c0 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3850,9 +3850,9 @@ 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` | ✓ |", + "| Opus 5.1 | anthropic:claude-opus-5-1 | `opus` | ✓ |", "| Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | |", "| Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | |", "| GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | |", @@ -5408,7 +5408,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " GH_TOKEN: ${{ steps.app-token.outputs.token }}", " run: |", " bunx @coder/xum@next run \\", - " --model anthropic:claude-opus-5 \\", + " --model anthropic:claude-opus-5-1 \\", " --thinking xhigh \\", " < .github/prompts/auto-cleanup.md", "```", diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index 81a5b3df74..93a1c78795 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -1650,7 +1650,7 @@ describe("ProviderService.setConfig", () => { const providersConfig = config.loadProvidersConfig(); expect(providersConfig?.["mux-gateway"]?.models).toEqual([ "anthropic/claude-sonnet-5", - "anthropic/claude-opus-5", + "anthropic/claude-opus-5-1", "openai/gpt-5.5", ]); expect(providersConfig?.["mux-gateway"]?.models).not.toContain("openai/gpt-5.2-codex"); diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index c2528c9a5f..bfbdade794 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -1515,7 +1515,7 @@ export class ProviderService { if (existingModels.length === 0) { providerConfig.models = [ "anthropic/claude-sonnet-5", - "anthropic/claude-opus-5", + "anthropic/claude-opus-5-1", "openai/gpt-5.5", ]; } diff --git a/src/node/services/workflows/WorkflowRunner.test.ts b/src/node/services/workflows/WorkflowRunner.test.ts index ceb2035252..197eafe1d4 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 38b13579f0..60f224be46 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -76,8 +76,8 @@ test.describe("slash command flows", () => { await ui.projects.openFirstWorkspace(); const modeToggles = page.locator('[data-component="ChatModeToggles"]'); - // Default model is Opus 5 - displayed as formatted name - await expect(modeToggles.getByText("Opus 5", { exact: true })).toBeVisible(); + // Default model is Opus 5.1 - displayed as formatted name + await expect(modeToggles.getByText("Opus 5.1", { exact: true })).toBeVisible(); await ui.chat.sendMessage("/model sonnet"); await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-sonnet-5");