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 79f56e5387..c1931d98f9 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -15,7 +15,7 @@ Xum ships with curated models kept up to date with the frontier. Use any custom | ---------------------- | ----------------------------- | ------------------------------------------------------------ | ------- | | 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 2d20eb5b49..d8fd02b978 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -123,6 +123,13 @@ export const AppConfigMigrationsSchema = z * 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 5a4a3f90c9..9ae01afeb7 100644 --- a/src/common/constants/knownModels.ts +++ b/src/common/constants/knownModels.ts @@ -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", @@ -280,6 +280,7 @@ export const MODEL_ABBREVIATIONS: Record = Object.fromEntries( // 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", }; diff --git a/src/common/utils/ai/modelDisplay.test.ts b/src/common/utils/ai/modelDisplay.test.ts index f5e4cc5928..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"); }); }); diff --git a/src/common/utils/ai/modelFallbacks.ts b/src/common/utils/ai/modelFallbacks.ts index d80878c16e..a55e6b6343 100644 --- a/src/common/utils/ai/modelFallbacks.ts +++ b/src/common/utils/ai/modelFallbacks.ts @@ -17,7 +17,9 @@ export const MODEL_FALLBACK_CHAIN_LIMIT = 3; * * Seeded into the config exactly once, guarded by * migrations.defaultModelFallbacksSeeded (plus the one-shot - * defaultModelFallbacksSeededFable51 re-seed for the key move to Fable 5.1) + * 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→ @@ -37,7 +39,24 @@ export const DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { * refusal fallback intact. */ export const LEGACY_DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { - "anthropic:claude-fable-5": { models: [KNOWN_MODELS.OPUS.id] }, + // 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 @@ -45,6 +64,7 @@ export const LEGACY_DEFAULT_MODEL_FALLBACKS: ModelFallbacks = { 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 8abea904a5..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"); diff --git a/src/common/utils/ai/models.ts b/src/common/utils/ai/models.ts index a58d7432b9..33f851e2c6 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -182,6 +182,7 @@ const ANTHROPIC_NATIVE_1M_PATTERNS = [ 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 6fe8c7658b..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 [ { diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index 6c58576bf1..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", diff --git a/src/common/utils/tokens/models-extra.ts b/src/common/utils/tokens/models-extra.ts index 81beb9a31b..6780539514 100644 --- a/src/common/utils/tokens/models-extra.ts +++ b/src/common/utils/tokens/models-extra.ts @@ -194,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 98f281c733..e51d572633 100644 --- a/src/common/utils/tools/tools.test.ts +++ b/src/common/utils/tools/tools.test.ts @@ -73,6 +73,7 @@ describe("supportsAnthropicNativeWebFetch", () => { ["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 dd152880f4..8cd1b6a8bc 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -114,6 +114,7 @@ describe("Config", () => { migrations: { defaultModelFallbacksSeeded: true, defaultModelFallbacksSeededFable51: true, + defaultModelFallbacksSeededOpus51: true, persistentSubagentsDefaulted: true, }, }) @@ -1555,6 +1556,7 @@ describe("Config", () => { migrations: { defaultModelFallbacksSeeded: true, defaultModelFallbacksSeededFable51: true, + defaultModelFallbacksSeededOpus51: true, }, modelFallbacks: { // Gateway-prefixed key + non-string chain entries + unknown trigger. @@ -1587,6 +1589,9 @@ describe("Config", () => { 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 () => { @@ -1596,7 +1601,7 @@ describe("Config", () => { // 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] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1608,7 +1613,7 @@ describe("Config", () => { migrations?: { defaultModelFallbacksSeeded?: unknown }; }; expect(raw.modelFallbacks).toEqual({ - [LEGACY_FABLE]: { models: [OPUS] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1683,6 +1688,144 @@ describe("Config", () => { 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(), @@ -1697,7 +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: [OPUS] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1709,7 +1852,7 @@ describe("Config", () => { }; expect(raw.modelFallbacks).toEqual({ "anthropic:claude-opus-4-6": { models: ["openai:gpt-5.5"] }, - [LEGACY_FABLE]: { models: [OPUS] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); expect(raw.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1730,7 +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: [OPUS] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, }); }); @@ -1751,7 +1894,7 @@ describe("Config", () => { // 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] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, }); expect(loaded.migrations?.defaultModelFallbacksSeeded).toBe(true); @@ -1797,14 +1940,14 @@ describe("Config", () => { const loaded = config.loadConfigOrDefault(); expect(loaded.modelFallbacks).toEqual({ [FABLE]: { enabled: false, models: ["openai:gpt-5.5"] }, - [LEGACY_FABLE]: { models: [OPUS] }, + [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: [OPUS] }, + [LEGACY_FABLE]: { models: [LEGACY_OPUS] }, [FABLE]: { models: [OPUS] }, }); @@ -1812,13 +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({ - [LEGACY_FABLE]: { models: [OPUS] }, + [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); }); }); @@ -2775,6 +2924,7 @@ describe("Config", () => { migrations: { defaultModelFallbacksSeeded: true, defaultModelFallbacksSeededFable51: true, + defaultModelFallbacksSeededOpus51: true, }, }) ); diff --git a/src/node/config.ts b/src/node/config.ts index 26ff2bcbd7..e32213f5e0 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -24,6 +24,7 @@ import type { 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"; @@ -562,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; @@ -1415,6 +1442,45 @@ export class Config { 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; + } + const modelFallbacks = normalizeModelFallbacks(parsed.modelFallbacks); const defaultModel = normalizeOptionalModelString(parsed.defaultModel); @@ -1595,6 +1661,7 @@ export class Config { 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 9148b88adf..9b68de03c0 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3852,7 +3852,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| ---------------------- | ----------------------------- | ------------------------------------------------------------ | ------- |", "| 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/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");