From 24db819de475a5ad4fd256a26a022dcf4f679a83 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:01:42 +0100 Subject: [PATCH 01/14] feat: add Claude Opus 5 model --- .../src/provider/Layers/ClaudeAdapter.test.ts | 27 ++++++++- .../src/provider/Layers/ClaudeProvider.ts | 41 ++++++++++--- .../provider/Layers/ProviderRegistry.test.ts | 60 +++++++++++++++++++ packages/contracts/src/model.ts | 10 +++- packages/shared/src/model.test.ts | 18 +++--- packages/shared/src/model.ts | 3 +- 6 files changed, 137 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 927d1fad9ca5..d88568651c14 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -658,6 +658,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Opus 5", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1615,7 +1638,7 @@ describe("ClaudeAdapterLive", () => { parent_tool_use_id: null, message: { id: "assistant-message-effective-model-alias", - model: "claude-opus-4-8-20260718", + model: "claude-opus-5-20260718", content: [{ type: "text", text: "Alias answer" }], }, } as unknown as SDKMessage); @@ -1628,7 +1651,7 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-effective-model-alias", uuid: "result-effective-model-alias", modelUsage: { - "claude-opus-4-8-20260718": { + "claude-opus-5-20260718": { contextWindow: 1_000_000, maxOutputTokens: 128_000, }, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9df672f54b0e..095a18253ffe 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -47,6 +47,7 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; +const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; @@ -84,8 +85,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }), }, { - slug: "claude-opus-4-8", - name: "Claude Opus 4.8", + slug: "claude-opus-5", + name: "Claude Opus 5", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -107,6 +108,15 @@ const BUILT_IN_MODELS: ReadonlyArray = [ id: "fastMode", label: "Fast Mode", }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), ], }), }, @@ -268,6 +278,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeOpus5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; +} + function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } @@ -284,6 +298,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-opus-5") { + return supportsClaudeOpus5(version); + } if (model.slug === "claude-fable-5") { return supportsClaudeFable5(version); } @@ -297,6 +314,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeOpus5UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; +} + function formatClaudeFable5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; @@ -356,6 +378,7 @@ export function normalizeClaudeCliEffort( if ( effort === "xhigh" && model !== "claude-fable-5" && + model !== "claude-opus-5" && model !== "claude-opus-4-8" && model !== "claude-sonnet-5" ) { @@ -798,13 +821,15 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeFable5(parsedVersion) + const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c3..ddfbcb480880 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1534,6 +1534,66 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); + assert.strictEqual(opus5?.name, "Claude Opus 5"); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 5 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-5"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index c2bfdefda2cd..d859443e514c 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -176,9 +176,13 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "gpt-5.3-spark": "gpt-5.3-codex-spark", }, [CLAUDE_DRIVER_KIND]: { - opus: "claude-opus-4-8", - "opus-4.8": "claude-opus-4-8", - "claude-opus-4.8": "claude-opus-4-8", + opus: "claude-opus-5", + "opus-5": "claude-opus-5", + "claude-opus-5.0": "claude-opus-5", + "claude-opus-5-0": "claude-opus-5", + "opus-4.8": "claude-opus-5", + "claude-opus-4.8": "claude-opus-5", + "claude-opus-4-8": "claude-opus-5", "opus-4.7": "claude-opus-4-7", "claude-opus-4.7": "claude-opus-4-7", "opus-4.6": "claude-opus-4-6", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 3bd3c49bb122..517519be1bd4 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -254,7 +254,7 @@ describe("pickModelSelectionFromInstances", () => { const sources: ReadonlyArray = [ source("codex", "codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.4-mini"]), source("claudeAgent", "claudeAgent", [ - "claude-opus-4-8", + "claude-opus-5", "claude-opus-4-6", "claude-sonnet-4-6", "claude-sonnet-5", @@ -268,9 +268,9 @@ describe("pickModelSelectionFromInstances", () => { ]; it("matches plain canonical models against the live provider lists", () => { - expect(pickModelSelectionFromInstances("claude-opus-4-8", sources)).toEqual({ + expect(pickModelSelectionFromInstances("claude-opus-5", sources)).toEqual({ instanceId: "claudeAgent", - model: "claude-opus-4-8", + model: "claude-opus-5", }); expect(pickModelSelectionFromInstances("gpt-5.4", sources)).toEqual({ instanceId: "codex", @@ -294,7 +294,7 @@ describe("pickModelSelectionFromInstances", () => { it("resolves registry aliases to the canonical live slug", () => { expect(pickModelSelectionFromInstances("opus", sources)).toEqual({ instanceId: "claudeAgent", - model: "claude-opus-4-8", + model: "claude-opus-5", }); expect(pickModelSelectionFromInstances("gpt-5-codex", sources)).toEqual({ instanceId: "codex", @@ -387,7 +387,7 @@ describe("pickModelSelectionFromInstances", () => { driverKind: ProviderDriverKind.make("claudeAgent"), models: [ { - slug: "claude-opus-4-8", + slug: "claude-opus-5", defaultOptions: [{ id: "effort", value: "high" }], optionDescriptors: [ { @@ -443,9 +443,9 @@ describe("pickModelSelectionFromInstances", () => { { id: "serviceTier", value: "fast" }, ], }); - expect(pickModelSelectionFromInstances("claude-opus-4-8", withDirectiveModels)).toEqual({ + expect(pickModelSelectionFromInstances("claude-opus-5", withDirectiveModels)).toEqual({ instanceId: "claudeAgent", - model: "claude-opus-4-8", + model: "claude-opus-5", options: [{ id: "effort", value: "xhigh" }], }); expect(pickModelSelectionFromInstances("claude-sonnet-5", withDirectiveModels)).toEqual({ @@ -576,7 +576,9 @@ describe("model slug normalization", () => { it("preserves exact custom slugs instead of expanding provider aliases", () => { const claude = ProviderDriverKind.make("claudeAgent"); - expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-4-8"); + expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); + expect(normalizeModelSlug("opus-4.8", claude)).toBe("claude-opus-5"); + expect(normalizeModelSlug("claude-opus-4-8", claude)).toBe("claude-opus-5"); expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index cbe3012c015a..82f5022f6363 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -365,6 +365,7 @@ function makeModelSelection( const THREAD_CREATION_DEFAULT_EFFORT_BY_MODEL: Record = { "gpt-5.5": "xhigh", + "claude-opus-5": "xhigh", "claude-opus-4-8": "xhigh", "claude-sonnet-5": "xhigh", "claude-fable-5": "high", @@ -417,7 +418,7 @@ function isThreadCreationEffortAdvertised( /** * Resolve a plain model name to a `ModelSelection` against the LIVE provider * model lists, so a caller never has to know or guess a harness/instance id — - * they pass e.g. `claude-opus-4-8`, `gpt-5.4`, or a future `fable-5` and the + * they pass e.g. `claude-opus-5`, `gpt-5.4`, or a future `fable-5` and the * official provider is found from the registry data itself (NO hardcoded * model-name patterns). Selection order: the native provider first * (`PROVIDER_INFERENCE_PRIORITY`, so Claude models resolve to `claudeAgent`, not From 6d85d418768f9b07bc059fe48261baff416a8d7a Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:34:17 +0100 Subject: [PATCH 02/14] fix: preserve legacy Opus fallback semantics --- .../src/provider/Layers/ClaudeAdapter.test.ts | 2 +- .../src/provider/Layers/ClaudeAdapter.ts | 1 + .../src/provider/Layers/ClaudeProvider.ts | 60 +++++++++---------- .../provider/Layers/ProviderRegistry.test.ts | 15 ++++- packages/contracts/src/model.ts | 1 - packages/shared/src/model.test.ts | 2 +- 6 files changed, 46 insertions(+), 35 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d88568651c14..ffb8307955cc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1458,7 +1458,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const requestedModel = createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-fable-5", + "claude-opus-5", ); const runtimeEventsFiber = yield* Stream.takeUntil( adapter.streamEvents, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e1f542415795..a6192a0e9d0e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -518,6 +518,7 @@ function selectedClaudeContextWindow( modelSelection: ModelSelection | undefined, ): number | undefined { switch (modelSelection?.model) { + case "claude-opus-5": case "claude-opus-4-8": case "claude-opus-4-7": // Always 1M at the API; these models expose no contextWindow option. diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 095a18253ffe..3d0d5e7ba9f3 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -52,6 +52,29 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; +const CLAUDE_OPUS_CAPABILITIES = createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { value: "ultracode", label: "Ultracode" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + ], +}); + const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-fable-5", @@ -88,37 +111,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ slug: "claude-opus-5", name: "Claude Opus 5", isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultracode", label: "Ultracode" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), + capabilities: CLAUDE_OPUS_CAPABILITIES, }, { slug: "claude-opus-4-7", @@ -336,6 +329,11 @@ function formatClaudeOpus47UpgradeMessage(version: string | null): string { export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); + // Persisted 4.8 selections remain runnable, but 4.8 is intentionally absent + // from the advertised model catalog now that Opus 5 replaces it. + if (slug === "claude-opus-4-8") { + return CLAUDE_OPUS_CAPABILITIES; + } return ( BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? DEFAULT_CLAUDE_MODEL_CAPABILITIES diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ddfbcb480880..0447c7628848 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,7 +31,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { checkClaudeProviderStatus, getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1541,11 +1541,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeCapabilities(), ); const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); + if (!opus5?.capabilities) { + assert.fail("Expected Claude Opus 5 capabilities on supported Claude Code versions."); + } assert.strictEqual(opus5?.name, "Claude Opus 5"); + assert.deepStrictEqual( + opus5.capabilities.optionDescriptors?.map((descriptor) => descriptor.id), + ["effort", "fastMode"], + ); assert.strictEqual( status.models.some((model) => model.slug === "claude-opus-4-8"), false, ); + assert.deepStrictEqual( + getClaudeModelCapabilities("claude-opus-4-8").optionDescriptors?.map( + (descriptor) => descriptor.id, + ), + ["effort", "fastMode"], + ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index d859443e514c..920ae0543000 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -182,7 +182,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "claude-opus-5-0": "claude-opus-5", "opus-4.8": "claude-opus-5", "claude-opus-4.8": "claude-opus-5", - "claude-opus-4-8": "claude-opus-5", "opus-4.7": "claude-opus-4-7", "claude-opus-4.7": "claude-opus-4-7", "opus-4.6": "claude-opus-4-6", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 517519be1bd4..e3d23b366dbf 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -578,7 +578,7 @@ describe("model slug normalization", () => { expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); expect(normalizeModelSlug("opus-4.8", claude)).toBe("claude-opus-5"); - expect(normalizeModelSlug("claude-opus-4-8", claude)).toBe("claude-opus-5"); + expect(normalizeModelSlug("claude-opus-4-8", claude)).toBe("claude-opus-4-8"); expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); }); From 2bbe5409f7773373e16861242f038e327d0fdaf9 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:49:30 +0100 Subject: [PATCH 03/14] fix: prune retired Claude model cache --- .../provider/Layers/ProviderRegistry.test.ts | 43 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 37 +++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0447c7628848..c9906c8bf4f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -563,6 +563,49 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("prunes retired Claude built-ins from cached provider models", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-03T00:00:00.000Z", + version: "2.1.218", + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-opus-4-8"), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-08-03T00:01:00.000Z", + version: "2.1.219", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-opus-5"), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-5"], + ); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e538301..9381cb013f2d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,16 +78,35 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER: Readonly>> = { + claudeAgent: new Set(["claude-opus-4-8"]), +}; + +const isRetiredBuiltInModel = ( + driver: ProviderDriverKind, + model: ServerProvider["models"][number], +): boolean => + model.isCustom === false && + (RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER[driver]?.has(model.slug) ?? false); + const mergeProviderModels = ( + driver: ProviderDriverKind, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { - return previousModels; + const retainedPreviousModels = previousModels.filter( + (model) => !isRetiredBuiltInModel(driver, model), + ); + const activeNextModels = nextModels.filter((model) => !isRetiredBuiltInModel(driver, model)); + + if (activeNextModels.length === 0 && retainedPreviousModels.length > 0) { + return retainedPreviousModels; } - const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); - const mergedModels = nextModels.map((model) => { + const previousBySlug = new Map( + retainedPreviousModels.map((model) => [model.slug, model] as const), + ); + const mergedModels = activeNextModels.map((model) => { const previousModel = previousBySlug.get(model.slug); if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { return model; @@ -97,8 +116,8 @@ const mergeProviderModels = ( capabilities: previousModel.capabilities, }; }); - const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + const nextSlugs = new Set(activeNextModels.map((model) => model.slug)); + return [...mergedModels, ...retainedPreviousModels.filter((model) => !nextSlugs.has(model.slug))]; }; export const mergeProviderSnapshot = ( @@ -109,7 +128,11 @@ export const mergeProviderSnapshot = ( ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels( + nextProvider.driver, + previousProvider.models, + nextProvider.models, + ), }; export const mergeProviderSnapshots = ( From 465a5958e4ea38462b75ebe6a8c2f75e8b5e660d Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:04:06 +0100 Subject: [PATCH 04/14] fix: honor Claude model version gates --- .../provider/Layers/ProviderRegistry.test.ts | 21 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 13 +++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c9906c8bf4f4..11015b82ff0d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -604,6 +604,27 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ["claude-opus-5"], ); + + const olderClaudeProvider = { + ...refreshedProvider, + checkedAt: "2026-08-03T00:02:00.000Z", + version: "2.1.218", + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-opus-4-7"), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(refreshedProvider, olderClaudeProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-4-7"], + ); }); it("fills missing capabilities from the previous provider snapshot", () => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 9381cb013f2d..36d2cf9fe36b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,8 +78,10 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); + const RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER: Readonly>> = { - claudeAgent: new Set(["claude-opus-4-8"]), + [CLAUDE_DRIVER_KIND]: new Set(["claude-opus-4-8"]), }; const isRetiredBuiltInModel = ( @@ -99,7 +101,7 @@ const mergeProviderModels = ( ); const activeNextModels = nextModels.filter((model) => !isRetiredBuiltInModel(driver, model)); - if (activeNextModels.length === 0 && retainedPreviousModels.length > 0) { + if (nextModels.length === 0 && retainedPreviousModels.length > 0) { return retainedPreviousModels; } @@ -117,7 +119,12 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(activeNextModels.map((model) => model.slug)); - return [...mergedModels, ...retainedPreviousModels.filter((model) => !nextSlugs.has(model.slug))]; + return [ + ...mergedModels, + ...retainedPreviousModels.filter( + (model) => !nextSlugs.has(model.slug) && (driver !== CLAUDE_DRIVER_KIND || model.isCustom), + ), + ]; }; export const mergeProviderSnapshot = ( From c9084d10ea12e9a27bbf21c7e6f050d912d6d74c Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:29:41 +0100 Subject: [PATCH 05/14] fix: filter pending Claude model catalog --- .../src/provider/Layers/ClaudeProvider.ts | 23 ++------- .../provider/Layers/ProviderRegistry.test.ts | 24 +++++++++- .../src/provider/Layers/ProviderRegistry.ts | 22 ++++----- .../src/provider/builtInProviderCatalog.ts | 11 +++++ .../src/provider/providerStatusCache.test.ts | 48 +++++++++++++++++++ .../src/provider/providerStatusCache.ts | 20 ++++++-- 6 files changed, 111 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 3d0d5e7ba9f3..0d3034d7f1c8 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -49,7 +49,6 @@ const CLAUDE_PRESENTATION = { } as const; const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; -const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const CLAUDE_OPUS_CAPABILITIES = createModelCapabilities({ @@ -279,10 +278,6 @@ function supportsClaudeFable5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; -} - function supportsClaudeOpus47(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; } @@ -297,9 +292,6 @@ function getBuiltInClaudeModelsForVersion( if (model.slug === "claude-fable-5") { return supportsClaudeFable5(version); } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } if (model.slug === "claude-opus-4-7") { return supportsClaudeOpus47(version); } @@ -317,11 +309,6 @@ function formatClaudeFable5UpgradeMessage(version: string | null): string { return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; } -function formatClaudeOpus48UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; -} - function formatClaudeOpus47UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; @@ -725,7 +712,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + getBuiltInClaudeModelsForVersion(null), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -823,11 +810,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? undefined : supportsClaudeFable5(parsedVersion) ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) + : supportsClaudeOpus47(parsedVersion) ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) @@ -885,7 +870,7 @@ export const makePendingClaudeProvider = ( Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + getBuiltInClaudeModelsForVersion(null), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 11015b82ff0d..3cc267e081c7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,7 +31,11 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus, getClaudeModelCapabilities } from "./ClaudeProvider.ts"; +import { + checkClaudeProviderStatus, + getClaudeModelCapabilities, + makePendingClaudeProvider, +} from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1598,6 +1602,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("keeps version-gated Claude models out of pending snapshots", () => + Effect.gen(function* () { + const status = yield* makePendingClaudeProvider(defaultClaudeSettings); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-5"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + }), + ); + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 36d2cf9fe36b..7a0325a43ff2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -53,7 +53,10 @@ import { } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + isRetiredBuiltInProviderModel, + type ProviderSnapshotSource, +} from "../builtInProviderCatalog.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -80,26 +83,17 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); -const RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER: Readonly>> = { - [CLAUDE_DRIVER_KIND]: new Set(["claude-opus-4-8"]), -}; - -const isRetiredBuiltInModel = ( - driver: ProviderDriverKind, - model: ServerProvider["models"][number], -): boolean => - model.isCustom === false && - (RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER[driver]?.has(model.slug) ?? false); - const mergeProviderModels = ( driver: ProviderDriverKind, previousModels: ReadonlyArray, nextModels: ReadonlyArray, ): ReadonlyArray => { const retainedPreviousModels = previousModels.filter( - (model) => !isRetiredBuiltInModel(driver, model), + (model) => !isRetiredBuiltInProviderModel(driver, model), + ); + const activeNextModels = nextModels.filter( + (model) => !isRetiredBuiltInProviderModel(driver, model), ); - const activeNextModels = nextModels.filter((model) => !isRetiredBuiltInModel(driver, model)); if (nextModels.length === 0 && retainedPreviousModels.length > 0) { return retainedPreviousModels; diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 559726c65eaa..69c818185fb1 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -2,6 +2,17 @@ import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3 import type * as Stream from "effect/Stream"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +const RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER: Readonly>> = { + claudeAgent: new Set(["claude-opus-4-8"]), +}; + +export const isRetiredBuiltInProviderModel = ( + driver: ProviderDriverKind, + model: ServerProvider["models"][number], +): boolean => + model.isCustom === false && + (RETIRED_BUILT_IN_MODEL_SLUGS_BY_DRIVER[driver]?.has(model.slug) ?? false); + export type ProviderSnapshotSource = { /** * Routing key — uniquely identifies this instance in the aggregated diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 07f67cd7de8f..a4ea6cd8ae46 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -182,6 +182,54 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("drops retired Claude built-ins while hydrating a versioned cache", () => { + const cachedClaude = makeProvider(CLAUDE_AGENT_DRIVER, { + version: "2.1.218", + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const pendingClaude = makeProvider(CLAUDE_AGENT_DRIVER, { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedClaude, + fallbackProvider: pendingClaude, + }).models.map((model) => model.slug), + ["claude-opus-4-6", "claude-opus-4-7"], + ); + }); + it("ignores stale cached enabled state when the provider is now disabled", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 2fe0424b4f57..be35d3af4112 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -11,17 +11,27 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { isRetiredBuiltInProviderModel } from "./builtInProviderCatalog.ts"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); const mergeProviderModels = ( + driver: ProviderDriverKind, fallbackModels: ReadonlyArray, cachedModels: ReadonlyArray, ): ReadonlyArray => { - const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))]; + const activeFallbackModels = fallbackModels.filter( + (model) => !isRetiredBuiltInProviderModel(driver, model), + ); + const fallbackSlugs = new Set(activeFallbackModels.map((model) => model.slug)); + return [ + ...activeFallbackModels, + ...cachedModels.filter( + (model) => !fallbackSlugs.has(model.slug) && !isRetiredBuiltInProviderModel(driver, model), + ), + ]; }; export const orderProviderSnapshots = ( @@ -59,7 +69,11 @@ export const hydrateCachedProvider = (input: { const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, - models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), + models: mergeProviderModels( + input.fallbackProvider.driver, + input.fallbackProvider.models, + input.cachedProvider.models, + ), installed: input.cachedProvider.installed, version: input.cachedProvider.version, status: input.cachedProvider.status, From ab4262adfd8d0c1b1c3fac6402821c4ad214d3fe Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:59:04 +0100 Subject: [PATCH 06/14] fix: preserve proven Claude catalog on errors --- .../src/provider/Layers/ClaudeProvider.ts | 13 ++--- .../provider/Layers/ProviderRegistry.test.ts | 51 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 8 ++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 0d3034d7f1c8..14edccef3910 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -780,6 +780,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + const versionedModels = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(parsedVersion), + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); if (version.code !== 0) { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { exitCode: version.code, @@ -790,7 +795,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models: allModels, + models: versionedModels, probe: { installed: true, version: parsedVersion, @@ -801,11 +806,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); + const models = versionedModels; const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined : supportsClaudeFable5(parsedVersion) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3cc267e081c7..9c833cd7c610 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -629,6 +629,57 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ["claude-opus-4-7"], ); + + const transientFailureProvider = { + ...olderClaudeProvider, + checkedAt: "2026-08-03T00:03:00.000Z", + version: null, + status: "error", + message: "Failed to execute Claude Agent CLI health check.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(refreshedProvider, transientFailureProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-4-7", "claude-opus-5"], + ); + + const firstFailureSnapshot = mergeProviderSnapshot( + refreshedProvider, + transientFailureProvider, + ); + const secondFailureProvider = { + ...transientFailureProvider, + checkedAt: "2026-08-03T00:04:00.000Z", + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(firstFailureSnapshot, secondFailureProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-4-7", "claude-opus-5"], + ); + + const previousCodexProvider = { + ...refreshedProvider, + driver: ProviderDriverKind.make("codex"), + models: [ + { + slug: "gpt-5.5", + name: "GPT-5.5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousCodexProvider, transientFailureProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-4-7"], + ); }); it("fills missing capabilities from the previous provider snapshot", () => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 7a0325a43ff2..42f8ec323d98 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -87,6 +87,7 @@ const mergeProviderModels = ( driver: ProviderDriverKind, previousModels: ReadonlyArray, nextModels: ReadonlyArray, + preserveMissingBuiltIns: boolean, ): ReadonlyArray => { const retainedPreviousModels = previousModels.filter( (model) => !isRetiredBuiltInProviderModel(driver, model), @@ -116,7 +117,9 @@ const mergeProviderModels = ( return [ ...mergedModels, ...retainedPreviousModels.filter( - (model) => !nextSlugs.has(model.slug) && (driver !== CLAUDE_DRIVER_KIND || model.isCustom), + (model) => + !nextSlugs.has(model.slug) && + (driver !== CLAUDE_DRIVER_KIND || model.isCustom || preserveMissingBuiltIns), ), ]; }; @@ -125,7 +128,7 @@ export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, ): ServerProvider => - !previousProvider + !previousProvider || previousProvider.driver !== nextProvider.driver ? nextProvider : { ...nextProvider, @@ -133,6 +136,7 @@ export const mergeProviderSnapshot = ( nextProvider.driver, previousProvider.models, nextProvider.models, + nextProvider.driver === CLAUDE_DRIVER_KIND && nextProvider.version === null, ), }; From 4f8f6ce453d25be50f10959895194286b48e38a6 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:41:37 +0100 Subject: [PATCH 07/14] fix: migrate persisted Opus 4.8 selections --- apps/web/src/modelSelection.test.ts | 19 +++++++++++++++++++ packages/shared/src/model.test.ts | 14 ++++++++++++++ packages/shared/src/model.ts | 20 +++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index e8fc8a244d02..08dc4fa5d45e 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -134,6 +134,25 @@ describe("instance-scoped model selection", () => { ).toBe("opus"); }); + it("migrates the retired built-in Opus 4.8 selection to Opus 5", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-opus-5", "claude-fable-5"], + }), + ]; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settingsWithProviderInstances(), + providers, + "claude-opus-4-8", + ), + ).toBe("claude-opus-5"); + }); + it("includes Grok custom models from the selected provider instance", () => { const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; const settings: UnifiedSettings = { diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index e3d23b366dbf..41f08e8b4a4c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -114,6 +114,7 @@ describe("resolveSelectableModel", () => { const options = [ { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { slug: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, ]; expect(resolveSelectableModel(ProviderDriverKind.make("codex"), "gpt-5.3-codex", options)).toBe( "gpt-5.3-codex", @@ -124,6 +125,15 @@ describe("resolveSelectableModel", () => { expect(resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "sonnet", options)).toBe( "claude-sonnet-5", ); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "claude-opus-4-8", options), + ).toBe("claude-opus-5"); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "claude-opus-4-8", [ + ...options, + { slug: "claude-opus-4-8", name: "Custom legacy Opus" }, + ]), + ).toBe("claude-opus-4-8"); }); }); @@ -296,6 +306,10 @@ describe("pickModelSelectionFromInstances", () => { instanceId: "claudeAgent", model: "claude-opus-5", }); + expect(pickModelSelectionFromInstances("claude-opus-4-8", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-5", + }); expect(pickModelSelectionFromInstances("gpt-5-codex", sources)).toEqual({ instanceId: "codex", model: "gpt-5.4", diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 82f5022f6363..d656a00d23cf 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -13,6 +13,21 @@ import { const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); +const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< + Record>> +> = { + claudeAgent: { + "claude-opus-4-8": "claude-opus-5", + }, +}; + +function resolveRetiredSelectableModelMigration( + provider: ProviderDriverKind, + value: string, +): string | undefined { + return RETIRED_SELECTABLE_MODEL_MIGRATIONS[provider]?.[value]; +} + export interface SelectableModelOption { slug: string; name: string; @@ -291,7 +306,8 @@ export function resolveSelectableModel( return byName.slug; } - const normalized = normalizeModelSlug(trimmed, provider); + const migrated = resolveRetiredSelectableModelMigration(provider, trimmed) ?? trimmed; + const normalized = normalizeModelSlug(migrated, provider); if (!normalized) { return null; } @@ -473,6 +489,8 @@ export function pickModelSelectionFromInstances( ? aliases[trimmed] : undefined; if (typeof canonical === "string") canonicals.add(canonical); + const migrated = resolveRetiredSelectableModelMigration(source.driverKind, trimmed); + if (typeof migrated === "string") canonicals.add(migrated); } let best: ModelSelection | null = null; let bestRank = Number.POSITIVE_INFINITY; From a3999057baf8249fa6aa0a25d039ccae7cf561c9 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:21:59 +0100 Subject: [PATCH 08/14] fix: keep retired Opus selections on Opus --- .../src/mcp/toolkits/subagent/handlers.ts | 1 + .../src/mcp/toolkits/thread/handlers.ts | 2 + packages/shared/src/model.test.ts | 74 ++++++++++++++ packages/shared/src/model.ts | 96 +++++++++++++------ 4 files changed, 144 insertions(+), 29 deletions(-) diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.ts b/apps/server/src/mcp/toolkits/subagent/handlers.ts index f30e929125fe..08b778e2abda 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.ts @@ -472,6 +472,7 @@ const buildModelSources = (runtime: SubagentRuntime) => driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index adfb7ea2a763..811b68cf0447 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -547,6 +547,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, @@ -728,6 +729,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 41f08e8b4a4c..79d74d07f760 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -134,6 +134,17 @@ describe("resolveSelectableModel", () => { { slug: "claude-opus-4-8", name: "Custom legacy Opus" }, ]), ).toBe("claude-opus-4-8"); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "claude-opus-4-8", [ + { slug: "claude-fable-5", name: "Claude Fable 5" }, + { slug: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ]), + ).toBe("claude-opus-4-7"); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "opus-4.8", [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + ]), + ).toBe("claude-opus-4-6"); }); }); @@ -250,11 +261,13 @@ describe("pickModelSelectionFromInstances", () => { id: string, driver: string, slugs: ReadonlyArray, + customSlugs: ReadonlyArray = [], ): ProviderModelSource => ({ instanceId: ProviderInstanceId.make(id), driverKind: ProviderDriverKind.make(driver), models: slugs.map((slug) => ({ slug, + isCustom: customSlugs.includes(slug), defaultOptions: undefined, optionDescriptors: undefined, })), @@ -324,6 +337,59 @@ describe("pickModelSelectionFromInstances", () => { }); }); + it("migrates retired aggregator Opus entries to the newest native Opus available", () => { + const withStaleAggregator: ReadonlyArray = [ + source("claudeAgent", "claudeAgent", ["claude-opus-5", "claude-opus-4-7"]), + source("cursor", "cursor", ["claude-opus-4-8"]), + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withStaleAggregator)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-5", + }); + + const withVersionGatedOpus5: ReadonlyArray = [ + source("claudeAgent", "claudeAgent", ["claude-fable-5", "claude-opus-4-7"]), + source("cursor", "cursor", ["claude-opus-5", "claude-opus-4-8"]), + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withVersionGatedOpus5)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-7", + }); + }); + + it("preserves an exact retired slug served directly by a Claude custom instance", () => { + const withClaudeCustom: ReadonlyArray = [ + source("claude_custom", "claudeAgent", ["claude-opus-4-8"], ["claude-opus-4-8"]), + source("claudeAgent", "claudeAgent", ["claude-opus-5"]), + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withClaudeCustom)).toEqual({ + instanceId: "claude_custom", + model: "claude-opus-4-8", + }); + }); + + it("preserves an exact retired slug owned by an aggregator custom model", () => { + const withCursorCustom: ReadonlyArray = [ + source("claudeAgent", "claudeAgent", ["claude-opus-5"]), + source("cursor", "cursor", ["claude-opus-4-8"], ["claude-opus-4-8"]), + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withCursorCustom)).toEqual({ + instanceId: "cursor", + model: "claude-opus-4-8", + }); + }); + + it("keeps native-provider priority for unrelated exact custom slug collisions", () => { + const withClaudeCollision: ReadonlyArray = [ + source("claude_custom", "claudeAgent", ["gpt-5.5"]), + source("codex", "codex", ["gpt-5.5"]), + ]; + expect(pickModelSelectionFromInstances("gpt-5.5", withClaudeCollision)).toEqual({ + instanceId: "codex", + model: "gpt-5.5", + }); + }); + it("routes a provider-specific alias's canonical model to the native provider", () => { // "opus-4.6-thinking" is a Cursor-only alias -> claude-opus-4-6, which both // claudeAgent and cursor serve. The native provider must still win. @@ -351,6 +417,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-6", + isCustom: false, defaultOptions: [ { id: "effort", value: "high" }, { id: "fastMode", value: true }, @@ -378,6 +445,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "gpt-5.5", + isCustom: false, defaultOptions: [ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "fast" }, @@ -402,6 +470,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "high" }], optionDescriptors: [ { @@ -417,6 +486,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-sonnet-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "medium" }], optionDescriptors: [ { @@ -432,6 +502,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-fable-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "xhigh" }], optionDescriptors: [ { @@ -482,6 +553,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, @@ -517,6 +589,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [{ id: "contextWindow", value: "1m" }], optionDescriptors: [ { @@ -551,6 +624,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d656a00d23cf..b53752f6d799 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -14,18 +14,20 @@ import { const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< - Record>> + Record>>> > = { claudeAgent: { - "claude-opus-4-8": "claude-opus-5", + "claude-opus-4-8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], + "opus-4.8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], + "claude-opus-4.8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], }, }; -function resolveRetiredSelectableModelMigration( +function resolveRetiredSelectableModelMigrations( provider: ProviderDriverKind, value: string, -): string | undefined { - return RETIRED_SELECTABLE_MODEL_MIGRATIONS[provider]?.[value]; +): ReadonlyArray { + return RETIRED_SELECTABLE_MODEL_MIGRATIONS[provider]?.[value] ?? []; } export interface SelectableModelOption { @@ -306,14 +308,18 @@ export function resolveSelectableModel( return byName.slug; } - const migrated = resolveRetiredSelectableModelMigration(provider, trimmed) ?? trimmed; - const normalized = normalizeModelSlug(migrated, provider); - if (!normalized) { - return null; + const candidates = [...resolveRetiredSelectableModelMigrations(provider, trimmed), trimmed]; + for (const candidate of candidates) { + const normalized = normalizeModelSlug(candidate, provider); + if (!normalized) { + continue; + } + const resolved = options.find((option) => option.slug === normalized); + if (resolved) { + return resolved.slug; + } } - - const resolved = options.find((option) => option.slug === normalized); - return resolved ? resolved.slug : null; + return null; } function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string { @@ -334,6 +340,7 @@ export function resolveModelSlugForProvider( /** A single model a provider instance serves, plus its default option selections. */ export interface ProviderModelEntry { readonly slug: string; + readonly isCustom: boolean; readonly defaultOptions: ReadonlyArray | undefined; readonly optionDescriptors: ReadonlyArray | undefined; } @@ -458,12 +465,17 @@ export function pickModelSelectionFromInstances( (preferInstanceId !== undefined && source.instanceId === preferInstanceId ? 0 : 1); // Best (highest-priority, source-preferred) instance that serves an exact slug. - const resolveSlug = (slug: string): ModelSelection | null => { + const resolveSlug = ( + slug: string, + acceptEntry: (entry: ProviderModelEntry) => boolean = () => true, + ): ModelSelection | null => { const best = sources - .filter((source) => source.models.some((entry) => entry.slug === slug)) + .filter((source) => source.models.some((entry) => entry.slug === slug && acceptEntry(entry))) .sort((a, b) => rank(a) - rank(b))[0]; if (best === undefined) return null; - const entry = best.models.find((candidate) => candidate.slug === slug); + const entry = best.models.find( + (candidate) => candidate.slug === slug && acceptEntry(candidate), + ); return makeModelSelection( best.instanceId, best.driverKind, @@ -473,6 +485,43 @@ export function pickModelSelectionFromInstances( ); }; + const resolveBestCanonical = (canonicals: ReadonlySet): ModelSelection | null => { + let best: ModelSelection | null = null; + let bestRank = Number.POSITIVE_INFINITY; + for (const canonical of canonicals) { + const selection = resolveSlug(canonical); + if (selection === null) continue; + const chosen = sources.find((source) => source.instanceId === selection.instanceId); + const selectionRank = chosen ? rank(chosen) : Number.POSITIVE_INFINITY; + if (selectionRank < bestRank) { + best = selection; + bestRank = selectionRank; + } + } + return best; + }; + + // Retired built-ins migrate before aggregator exact matches. When the latest + // replacement is version-gated, stay on the newest Opus model the native + // Claude instance actually serves instead of falling to an unrelated model. + const migrations = new Set(); + for (const source of sources) { + for (const migrated of resolveRetiredSelectableModelMigrations(source.driverKind, trimmed)) { + migrations.add(migrated); + } + } + + // Preserve an exact provider-owned custom model before migrating a retired + // built-in. This is gated to retired inputs so unrelated slug collisions + // still use normal native-provider priority. + if (migrations.size > 0) { + const directCustom = resolveSlug(trimmed, (entry) => entry.isCustom); + if (directCustom !== null) return directCustom; + } + + const migrated = resolveBestCanonical(migrations); + if (migrated !== null) return migrated; + // 1. Direct match against the models each provider actually serves. const direct = resolveSlug(trimmed); if (direct !== null) return direct; @@ -489,22 +538,11 @@ export function pickModelSelectionFromInstances( ? aliases[trimmed] : undefined; if (typeof canonical === "string") canonicals.add(canonical); - const migrated = resolveRetiredSelectableModelMigration(source.driverKind, trimmed); - if (typeof migrated === "string") canonicals.add(migrated); - } - let best: ModelSelection | null = null; - let bestRank = Number.POSITIVE_INFINITY; - for (const canonical of canonicals) { - const selection = resolveSlug(canonical); - if (selection === null) continue; - const chosen = sources.find((source) => source.instanceId === selection.instanceId); - const selectionRank = chosen ? rank(chosen) : Number.POSITIVE_INFINITY; - if (selectionRank < bestRank) { - best = selection; - bestRank = selectionRank; + for (const migrated of resolveRetiredSelectableModelMigrations(source.driverKind, trimmed)) { + canonicals.add(migrated); } } - return best; + return resolveBestCanonical(canonicals); } /** Trim a string, returning null for empty/missing values. */ From d1324fb531ff2659c3ff63f6da8ab9af19144d74 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:42:02 +0100 Subject: [PATCH 09/14] fix: preserve generic Opus fallback --- packages/shared/src/model.test.ts | 19 +++++++++++++++++++ packages/shared/src/model.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 79d74d07f760..424beac333e3 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -145,6 +145,17 @@ describe("resolveSelectableModel", () => { { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, ]), ).toBe("claude-opus-4-6"); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "opus", [ + { slug: "claude-fable-5", name: "Claude Fable 5" }, + { slug: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ]), + ).toBe("claude-opus-4-7"); + expect( + resolveSelectableModel(ProviderDriverKind.make("claudeAgent"), "opus-5", [ + { slug: "claude-opus-4-7", name: "Claude Opus 4.7" }, + ]), + ).toBeNull(); }); }); @@ -355,6 +366,14 @@ describe("pickModelSelectionFromInstances", () => { instanceId: "claudeAgent", model: "claude-opus-4-7", }); + expect(pickModelSelectionFromInstances("opus", withVersionGatedOpus5)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-7", + }); + expect(pickModelSelectionFromInstances("opus-5", withVersionGatedOpus5)).toEqual({ + instanceId: "cursor", + model: "claude-opus-5", + }); }); it("preserves an exact retired slug served directly by a Claude custom instance", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b53752f6d799..88a1802036f9 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -17,6 +17,7 @@ const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< Record>>> > = { claudeAgent: { + opus: ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], "claude-opus-4-8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], "opus-4.8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], "claude-opus-4.8": ["claude-opus-5", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5"], From 79eeb156db4b79890f518fa8f2cd5b6793b81a12 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:16:35 +0100 Subject: [PATCH 10/14] fix: retain proven Claude catalog on probe errors --- .../src/provider/Drivers/ClaudeDriver.ts | 12 +++ .../src/provider/Layers/ClaudeProvider.ts | 31 ++++++++ .../provider/Layers/ProviderRegistry.test.ts | 76 +++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949cf..3e8550a2792d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -19,6 +19,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -32,6 +33,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + reconcileKnownClaudeModelsAfterVersionProbe, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -160,6 +162,7 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const knownVersionedModels = yield* Ref.make([]); const checkProvider = checkClaudeProviderStatus( effectiveConfig, @@ -167,6 +170,15 @@ export const ClaudeDriver: ProviderDriver = { processEnv, ).pipe( Effect.map(stampIdentity), + Effect.flatMap((nextSnapshot) => + Ref.modify(knownVersionedModels, (knownModels) => { + const reconciled = reconcileKnownClaudeModelsAfterVersionProbe( + knownModels, + nextSnapshot, + ); + return [reconciled.snapshot, reconciled.knownModels]; + }), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 14edccef3910..d9f2f21b5238 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,7 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, + type ServerProvider, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -299,6 +300,36 @@ function getBuiltInClaudeModelsForVersion( }); } +export function reconcileKnownClaudeModelsAfterVersionProbe( + knownModels: ReadonlyArray, + nextSnapshot: ServerProvider, +): { + readonly snapshot: ServerProvider; + readonly knownModels: ReadonlyArray; +} { + if (!nextSnapshot.installed) { + return { snapshot: nextSnapshot, knownModels: [] }; + } + if (nextSnapshot.version !== null) { + return { snapshot: nextSnapshot, knownModels: nextSnapshot.models }; + } + if (nextSnapshot.status !== "error" || knownModels.length === 0) { + return { snapshot: nextSnapshot, knownModels }; + } + + const nextSlugs = new Set(nextSnapshot.models.map((model) => model.slug)); + return { + snapshot: { + ...nextSnapshot, + models: [ + ...nextSnapshot.models, + ...knownModels.filter((model) => !nextSlugs.has(model.slug)), + ], + }, + knownModels, + }; +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9c833cd7c610..1654d8c4a901 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -35,6 +35,7 @@ import { checkClaudeProviderStatus, getClaudeModelCapabilities, makePendingClaudeProvider, + reconcileKnownClaudeModelsAfterVersionProbe, } from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; @@ -1671,6 +1672,81 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it("preserves proven models on transient failures but clears them across uninstall", () => { + const knownModels = [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-opus-5"), + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-sonnet-4-6"), + }, + ] as const; + const failureModels = [ + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-sonnet-4-6"), + }, + ] as const; + const readySnapshot = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-03T00:00:00.000Z", + version: "2.1.219", + models: knownModels, + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const learned = reconcileKnownClaudeModelsAfterVersionProbe([], readySnapshot); + const transientFailure = reconcileKnownClaudeModelsAfterVersionProbe(learned.knownModels, { + ...readySnapshot, + status: "error", + checkedAt: "2026-08-03T00:01:00.000Z", + version: null, + models: failureModels, + }); + assert.deepStrictEqual( + transientFailure.snapshot.models.map((model) => model.slug), + ["claude-sonnet-4-6", "claude-opus-5"], + ); + + const missing = reconcileKnownClaudeModelsAfterVersionProbe(transientFailure.knownModels, { + ...readySnapshot, + status: "error", + installed: false, + checkedAt: "2026-08-03T00:02:00.000Z", + version: null, + models: failureModels, + }); + assert.deepStrictEqual(missing.knownModels, []); + + const replacementFailure = reconcileKnownClaudeModelsAfterVersionProbe( + missing.knownModels, + { + ...readySnapshot, + status: "error", + checkedAt: "2026-08-03T00:03:00.000Z", + version: null, + models: failureModels, + }, + ); + assert.deepStrictEqual( + replacementFailure.snapshot.models.map((model) => model.slug), + ["claude-sonnet-4-6"], + ); + }); + it.effect("includes Claude Opus 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( From 1518f5fb75fff4a93549eff418ea879b07371bc3 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:40:40 +0100 Subject: [PATCH 11/14] fix: constrain Claude model retention --- .../src/provider/Layers/ClaudeProvider.ts | 14 ++++++++- .../provider/Layers/ProviderRegistry.test.ts | 30 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 5 +++- packages/shared/src/model.test.ts | 8 +++++ packages/shared/src/model.ts | 23 +++++++++++--- 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d9f2f21b5238..51d41b0bb9ba 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -300,6 +300,15 @@ function getBuiltInClaudeModelsForVersion( }); } +function isVersionGatedClaudeBuiltInModel(model: ServerProviderModel): boolean { + return ( + !model.isCustom && + (model.slug === "claude-opus-5" || + model.slug === "claude-fable-5" || + model.slug === "claude-opus-4-7") + ); +} + export function reconcileKnownClaudeModelsAfterVersionProbe( knownModels: ReadonlyArray, nextSnapshot: ServerProvider, @@ -311,7 +320,10 @@ export function reconcileKnownClaudeModelsAfterVersionProbe( return { snapshot: nextSnapshot, knownModels: [] }; } if (nextSnapshot.version !== null) { - return { snapshot: nextSnapshot, knownModels: nextSnapshot.models }; + return { + snapshot: nextSnapshot, + knownModels: nextSnapshot.models.filter(isVersionGatedClaudeBuiltInModel), + }; } if (nextSnapshot.status !== "error" || knownModels.length === 0) { return { snapshot: nextSnapshot, knownModels }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1654d8c4a901..013aa24d89e4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -646,6 +646,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ["claude-opus-4-7", "claude-opus-5"], ); + const missingClaudeProvider = { + ...transientFailureProvider, + installed: false, + checkedAt: "2026-08-03T00:03:30.000Z", + models: [ + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: getClaudeModelCapabilities("claude-sonnet-4-6"), + }, + ], + } satisfies ServerProvider; + assert.deepStrictEqual( + mergeProviderSnapshot(refreshedProvider, missingClaudeProvider).models.map( + (model) => model.slug, + ), + ["claude-sonnet-4-6"], + ); + const firstFailureSnapshot = mergeProviderSnapshot( refreshedProvider, transientFailureProvider, @@ -1686,6 +1706,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te isCustom: false, capabilities: getClaudeModelCapabilities("claude-sonnet-4-6"), }, + { + slug: "removed-custom-opus", + name: "Removed Custom Opus", + isCustom: true, + capabilities: getClaudeModelCapabilities("claude-opus-5"), + }, ] as const; const failureModels = [ { @@ -1709,6 +1735,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const learned = reconcileKnownClaudeModelsAfterVersionProbe([], readySnapshot); + assert.deepStrictEqual( + learned.knownModels.map((model) => model.slug), + ["claude-opus-5"], + ); const transientFailure = reconcileKnownClaudeModelsAfterVersionProbe(learned.knownModels, { ...readySnapshot, status: "error", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 42f8ec323d98..aa0dd8f0c59d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -136,7 +136,10 @@ export const mergeProviderSnapshot = ( nextProvider.driver, previousProvider.models, nextProvider.models, - nextProvider.driver === CLAUDE_DRIVER_KIND && nextProvider.version === null, + nextProvider.driver === CLAUDE_DRIVER_KIND && + nextProvider.installed && + nextProvider.status === "error" && + nextProvider.version === null, ), }; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 424beac333e3..1142194ae17a 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -370,6 +370,14 @@ describe("pickModelSelectionFromInstances", () => { instanceId: "claudeAgent", model: "claude-opus-4-7", }); + const withCrossProviderCustomOpus5: ReadonlyArray = [ + source("codex", "codex", ["claude-opus-5"], ["claude-opus-5"]), + source("claudeAgent", "claudeAgent", ["claude-opus-4-7"]), + ]; + expect(pickModelSelectionFromInstances("opus", withCrossProviderCustomOpus5)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-7", + }); expect(pickModelSelectionFromInstances("opus-5", withVersionGatedOpus5)).toEqual({ instanceId: "cursor", model: "claude-opus-5", diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 88a1802036f9..43d5bb7ded85 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); +const CLAUDE_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< Record>>> @@ -469,9 +470,14 @@ export function pickModelSelectionFromInstances( const resolveSlug = ( slug: string, acceptEntry: (entry: ProviderModelEntry) => boolean = () => true, + acceptSource: (source: ProviderModelSource) => boolean = () => true, ): ModelSelection | null => { const best = sources - .filter((source) => source.models.some((entry) => entry.slug === slug && acceptEntry(entry))) + .filter( + (source) => + acceptSource(source) && + source.models.some((entry) => entry.slug === slug && acceptEntry(entry)), + ) .sort((a, b) => rank(a) - rank(b))[0]; if (best === undefined) return null; const entry = best.models.find( @@ -486,11 +492,15 @@ export function pickModelSelectionFromInstances( ); }; - const resolveBestCanonical = (canonicals: ReadonlySet): ModelSelection | null => { + const resolveBestCanonical = ( + canonicals: ReadonlySet, + acceptEntry?: (entry: ProviderModelEntry) => boolean, + acceptSource?: (source: ProviderModelSource) => boolean, + ): ModelSelection | null => { let best: ModelSelection | null = null; let bestRank = Number.POSITIVE_INFINITY; for (const canonical of canonicals) { - const selection = resolveSlug(canonical); + const selection = resolveSlug(canonical, acceptEntry, acceptSource); if (selection === null) continue; const chosen = sources.find((source) => source.instanceId === selection.instanceId); const selectionRank = chosen ? rank(chosen) : Number.POSITIVE_INFINITY; @@ -520,8 +530,13 @@ export function pickModelSelectionFromInstances( if (directCustom !== null) return directCustom; } - const migrated = resolveBestCanonical(migrations); + const migrated = resolveBestCanonical( + migrations, + (entry) => !entry.isCustom, + (source) => source.driverKind === CLAUDE_PROVIDER_DRIVER_KIND, + ); if (migrated !== null) return migrated; + if (migrations.size > 0) return null; // 1. Direct match against the models each provider actually serves. const direct = resolveSlug(trimmed); From c93d890b5032bd097cf1efb298acce209ffd7ae7 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:04:29 +0100 Subject: [PATCH 12/14] fix: reserve Claude built-in model slugs --- .../src/provider/Layers/ClaudeProvider.ts | 33 +++++++++++-------- .../provider/Layers/ProviderRegistry.test.ts | 26 +++++++++++++-- apps/server/src/provider/providerSnapshot.ts | 6 +++- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 51d41b0bb9ba..324c3493df9a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -270,6 +270,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }), }, ]; +const RESERVED_CLAUDE_MODEL_SLUGS = new Set([ + ...BUILT_IN_MODELS.map((model) => model.slug), + "claude-opus-4-8", +]); function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; @@ -300,6 +304,18 @@ function getBuiltInClaudeModelsForVersion( }); } +function providerClaudeModelsFromSettings( + version: string | null | undefined, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(version), + customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + RESERVED_CLAUDE_MODEL_SLUGS, + ); +} + function isVersionGatedClaudeBuiltInModel(model: ServerProviderModel): boolean { return ( !model.isCustom && @@ -754,11 +770,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); - const allModels = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(null), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); + const allModels = providerClaudeModelsFromSettings(null, claudeSettings.customModels); if (!claudeSettings.enabled) { return buildServerProvider({ @@ -823,10 +835,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - const versionedModels = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + const versionedModels = providerClaudeModelsFromSettings( + parsedVersion, claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); if (version.code !== 0) { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { @@ -913,11 +924,7 @@ export const makePendingClaudeProvider = ( ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; - const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(null), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); + const models = providerClaudeModelsFromSettings(null, claudeSettings.customModels); if (!claudeSettings.enabled) { return buildServerProvider({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 013aa24d89e4..31004a9463de 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1676,7 +1676,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("keeps version-gated Claude models out of pending snapshots", () => Effect.gen(function* () { - const status = yield* makePendingClaudeProvider(defaultClaudeSettings); + const status = yield* makePendingClaudeProvider({ + ...defaultClaudeSettings, + customModels: ["claude-opus-5", "claude-opus-4-8", "my-custom-model"], + }); assert.strictEqual( status.models.some((model) => model.slug === "claude-opus-5"), false, @@ -1689,6 +1692,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te status.models.some((model) => model.slug === "claude-opus-4-7"), false, ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "my-custom-model"), + true, + ); }), ); @@ -1822,13 +1833,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("hides Claude Opus 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, + { + ...defaultClaudeSettings, + customModels: ["claude-opus-5", "claude-opus-4-8", "my-custom-model"], + }, claudeCapabilities(), ); assert.strictEqual( status.models.some((model) => model.slug === "claude-opus-5"), false, ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-8"), + false, + ); + assert.strictEqual( + status.models.some((model) => model.slug === "my-custom-model"), + true, + ); assert.strictEqual( status.message, "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d0..22225780250f 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -142,9 +142,13 @@ export function providerModelsFromSettings( builtInModels: ReadonlyArray, customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, + reservedSlugs?: ReadonlySet, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; - const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); + const seen = new Set([ + ...resolvedBuiltInModels.map((model) => model.slug), + ...(reservedSlugs ?? []), + ]); const customEntries: ServerProviderModel[] = []; for (const candidate of customModels) { From 9691147d123929e7291fc9b238f2cdd395e373e3 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:22:48 +0100 Subject: [PATCH 13/14] fix: preserve legacy Claude model boundaries --- .../server/src/mcp/toolkits/subagent/tools.ts | 4 +- .../src/provider/Layers/ClaudeProvider.ts | 8 +- apps/web/src/components/chat/ChatComposer.tsx | 7 +- apps/web/src/composerDraftStore.test.ts | 88 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 32 ++++++- apps/web/src/modelSelection.test.ts | 55 +++++++++++- apps/web/src/modelSelection.ts | 21 ++++- packages/shared/src/model.test.ts | 11 +++ packages/shared/src/model.ts | 22 +++++ 9 files changed, 233 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/toolkits/subagent/tools.ts b/apps/server/src/mcp/toolkits/subagent/tools.ts index 7c88ad7d9309..ee10054a0c7c 100644 --- a/apps/server/src/mcp/toolkits/subagent/tools.ts +++ b/apps/server/src/mcp/toolkits/subagent/tools.ts @@ -111,7 +111,7 @@ export const ScheduleCreateInput = Schema.Struct({ intervalSeconds: Schema.optionalKey(Schema.Int), cronExpr: Schema.optionalKey(Schema.String), timezone: Schema.optionalKey(Schema.String), - // Optional plain model name (e.g. "claude-opus-4-8" or "gpt-5.4"); the + // Optional plain model name (e.g. "claude-opus-5" or "gpt-5.4"); the // provider/harness is inferred from the live model lists, so the caller never // guesses a harness/instance id. Omit to inherit the thread's current model. model: Schema.optionalKey(TrimmedNonEmptyString), @@ -196,7 +196,7 @@ export const SubagentsTool = Tool.make("t3_subagents", { export const ScheduleCreateTool = Tool.make("t3_schedule_create", { description: - "Schedule a recurring prompt to be sent to a thread (defaults to the calling thread). Provide exactly one of intervalSeconds (fixed interval) or cronExpr (a cron expression, validated on create); timezone defaults to UTC and the busy policy always defaults to skip. The same thread is reused on every trigger. To pin the model each run uses, pass `model` as a plain model name (e.g. 'claude-opus-4-8' or 'gpt-5.4'); the provider/harness is inferred automatically, so you never guess a harness/instance id. Pin a model on the thread's own provider (like the interactive model picker) — pinning a different provider than the thread's active session errors at run time, so prefer a dedicated thread for a cross-provider schedule. Omit `model` to inherit the thread's current model on each run.", + "Schedule a recurring prompt to be sent to a thread (defaults to the calling thread). Provide exactly one of intervalSeconds (fixed interval) or cronExpr (a cron expression, validated on create); timezone defaults to UTC and the busy policy always defaults to skip. The same thread is reused on every trigger. To pin the model each run uses, pass `model` as a plain model name (e.g. 'claude-opus-5' or 'gpt-5.4'); the provider/harness is inferred automatically, so you never guess a harness/instance id. Pin a model on the thread's own provider (like the interactive model picker) — pinning a different provider than the thread's active session errors at run time, so prefer a dedicated thread for a cross-provider schedule. Omit `model` to inherit the thread's current model on each run.", parameters: ScheduleCreateInput, success: ScheduleEntry, failure: ThreadStartToolError, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 324c3493df9a..419b13761565 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + CLAUDE_RESERVED_MODEL_SLUGS, createModelCapabilities, getModelSelectionStringOptionValue, getProviderOptionCurrentValue, @@ -270,11 +271,6 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }), }, ]; -const RESERVED_CLAUDE_MODEL_SLUGS = new Set([ - ...BUILT_IN_MODELS.map((model) => model.slug), - "claude-opus-4-8", -]); - function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } @@ -312,7 +308,7 @@ function providerClaudeModelsFromSettings( getBuiltInClaudeModelsForVersion(version), customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, - RESERVED_CLAUDE_MODEL_SLUGS, + CLAUDE_RESERVED_MODEL_SLUGS, ); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 787395a10fdd..048133db8a0c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,7 +41,11 @@ import { replaceTextRange, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + deriveComposerSendState, + readFileAsDataUrl, + threadHasEstablishedProviderBinding, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -802,6 +806,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providers: providerStatuses, selectedProvider, selectedInstanceId, + hasEstablishedProviderBinding: threadHasEstablishedProviderBinding(activeThread), threadModelSelection: activeThreadModelSelection, projectModelSelection: activeProjectDefaultModelSelection, settings, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b71073064..5c1eee43584c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -14,7 +14,9 @@ import { ThreadId, type ModelSelection, type ProviderOptionSelection, + type ServerProvider, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; // The composer draft's `modelSelectionByProvider` and @@ -60,6 +62,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + deriveEffectiveComposerModelState, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -1148,6 +1151,91 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); +describe("deriveEffectiveComposerModelState", () => { + const providers: ReadonlyArray = [ + { + instanceId: CLAUDE_AGENT_INSTANCE, + driver: CLAUDE_AGENT_DRIVER, + enabled: true, + installed: true, + version: "2.1.219", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-03T00:00:00.000Z", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: {}, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + isDefault: true, + capabilities: {}, + }, + ], + slashCommands: [], + skills: [], + }, + ]; + + const resolve = (input: { + hasEstablishedProviderBinding: boolean; + threadModelSelection: ModelSelection | null; + projectModelSelection: ModelSelection | null; + }) => + deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: CLAUDE_AGENT_DRIVER, + selectedInstanceId: CLAUDE_AGENT_INSTANCE, + settings: DEFAULT_UNIFIED_SETTINGS, + ...input, + }).selectedModel; + + it("preserves exact retired Opus for an existing thread session", () => { + expect( + resolve({ + hasEstablishedProviderBinding: true, + threadModelSelection: { + instanceId: CLAUDE_AGENT_INSTANCE, + model: "claude-opus-4-8", + }, + projectModelSelection: null, + }), + ).toBe("claude-opus-4-8"); + }); + + it("migrates exact retired Opus for a new thread project selection", () => { + expect( + resolve({ + hasEstablishedProviderBinding: false, + threadModelSelection: null, + projectModelSelection: { + instanceId: CLAUDE_AGENT_INSTANCE, + model: "claude-opus-4-8", + }, + }), + ).toBe("claude-opus-5"); + }); + + it("migrates exact retired Opus for an unstarted thread selection", () => { + expect( + resolve({ + hasEstablishedProviderBinding: false, + threadModelSelection: { + instanceId: CLAUDE_AGENT_INSTANCE, + model: "claude-opus-4-8", + }, + projectModelSelection: null, + }), + ).toBe("claude-opus-5"); + }); +}); + describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.make("thread-model-options"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b183..08549459fff0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -29,7 +29,11 @@ import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { + CLAUDE_RETIRED_MODEL_SLUGS, + createModelSelection, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; @@ -956,13 +960,33 @@ export function deriveEffectiveComposerModelState(input: { * collapsing to the default Codex bucket. */ selectedInstanceId?: ProviderInstanceId | null | undefined; + hasEstablishedProviderBinding: boolean; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { + const exactRetiredThreadModel = ( + instanceId: ProviderInstanceId, + candidate: string | null | undefined, + ): string | null => { + const trimmed = candidate?.trim(); + if ( + !input.hasEstablishedProviderBinding || + !trimmed || + input.threadModelSelection?.instanceId !== instanceId || + input.threadModelSelection.model !== trimmed || + !CLAUDE_RETIRED_MODEL_SLUGS.has(trimmed) + ) { + return null; + } + return trimmed; + }; const baseModelCandidate = input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; const baseModel = + (input.selectedInstanceId + ? exactRetiredThreadModel(input.selectedInstanceId, baseModelCandidate) + : null) ?? (input.selectedInstanceId ? resolveAppModelSelectionForInstance( input.selectedInstanceId, @@ -993,7 +1017,8 @@ export function deriveEffectiveComposerModelState(input: { ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) : ProviderInstanceId.make(input.selectedProvider); const selectedModel = activeSelection?.model - ? (resolveAppModelSelectionForInstance( + ? (exactRetiredThreadModel(activeSelectionInstanceId, activeSelection.model) ?? + resolveAppModelSelectionForInstance( activeSelectionInstanceId, input.settings, input.providers, @@ -3461,6 +3486,7 @@ export function useEffectiveComposerModelState(input: { * instance reads its own model, not the default Codex's. */ selectedInstanceId?: ProviderInstanceId | null | undefined; + hasEstablishedProviderBinding: boolean; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; @@ -3474,6 +3500,7 @@ export function useEffectiveComposerModelState(input: { providers: input.providers, selectedProvider: input.selectedProvider, selectedInstanceId: input.selectedInstanceId, + hasEstablishedProviderBinding: input.hasEstablishedProviderBinding, threadModelSelection: input.threadModelSelection, projectModelSelection: input.projectModelSelection, settings: input.settings, @@ -3483,6 +3510,7 @@ export function useEffectiveComposerModelState(input: { input.providers, input.settings, input.projectModelSelection, + input.hasEstablishedProviderBinding, input.selectedInstanceId, input.selectedProvider, input.threadModelSelection, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 08dc4fa5d45e..0edec5e898cc 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contrac import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "./providerInstances"; import { + getAppModelOptions, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -12,6 +13,7 @@ function provider(input: { provider?: ProviderDriverKind; instanceId: string; models?: ReadonlyArray; + defaultModel?: string; }): ServerProvider { const driver = input.provider ?? @@ -32,6 +34,7 @@ function provider(input: { name: slug, isCustom: false, capabilities: {}, + ...(slug === input.defaultModel ? { isDefault: true } : {}), })), slashCommands: [], skills: [], @@ -134,12 +137,13 @@ describe("instance-scoped model selection", () => { ).toBe("opus"); }); - it("migrates the retired built-in Opus 4.8 selection to Opus 5", () => { + it("migrates an exact retired Opus 4.8 selection outside an existing session", () => { const providers = [ provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent", - models: ["claude-opus-5", "claude-fable-5"], + models: ["claude-opus-5", "claude-sonnet-4-6"], + defaultModel: "claude-sonnet-4-6", }), ]; @@ -151,6 +155,53 @@ describe("instance-scoped model selection", () => { "claude-opus-4-8", ), ).toBe("claude-opus-5"); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settingsWithProviderInstances(), + providers, + "opus-4.8", + ), + ).toBe("claude-opus-5"); + }); + + it("does not restore reserved Claude slugs from custom settings", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-sonnet-4-6"], + }), + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { + customModels: [ + "claude-opus-5", + "claude-fable-5", + "claude-opus-4-7", + "claude-opus-4-8", + "kept-custom", + ], + }, + }, + }, + }; + const stock = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ + "claude-sonnet-4-6", + "kept-custom", + ]); + expect( + getAppModelOptions(settings, providers, ProviderDriverKind.make("claudeAgent")).map( + (option) => option.slug, + ), + ).toEqual(["claude-sonnet-4-6", "kept-custom"]); }); it("includes Grok custom models from the selected provider instance", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ec089d766cf0..47570d7b22f6 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -8,6 +8,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { + CLAUDE_RESERVED_MODEL_SLUGS, createModelSelection, normalizeCustomModelSlug, resolveSelectableModel, @@ -116,6 +117,16 @@ function applyInstanceModelPreferences( ); } +function customModelExclusions( + provider: ProviderDriverKind, + builtInModelSlugs: ReadonlySet, +): ReadonlySet { + if (provider !== ProviderDriverKind.make("claudeAgent")) { + return builtInModelSlugs; + } + return new Set([...builtInModelSlugs, ...CLAUDE_RESERVED_MODEL_SLUGS]); +} + export function normalizeCustomModelSlugs( models: Iterable, builtInModelSlugs: ReadonlySet, @@ -164,7 +175,10 @@ export function getAppModelOptions( // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { + for (const slug of normalizeCustomModelSlugs( + customModels, + customModelExclusions(provider, builtInModelSlugs), + )) { if (seen.has(slug)) { continue; } @@ -207,7 +221,10 @@ export function getAppModelOptionsForInstance( ); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { + for (const slug of normalizeCustomModelSlugs( + customModels, + customModelExclusions(entry.driverKind, builtInModelSlugs), + )) { if (seen.has(slug)) { continue; } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 1142194ae17a..0c9f1952928c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -384,6 +384,17 @@ describe("pickModelSelectionFromInstances", () => { }); }); + it("preserves exact retired Opus when the native provider still serves it", () => { + const withNativeLegacyOpus: ReadonlyArray = [ + source("cursor", "cursor", ["claude-opus-4-8"]), + source("claudeAgent", "claudeAgent", ["claude-opus-4-8", "claude-sonnet-4-6"]), + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withNativeLegacyOpus)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-8", + }); + }); + it("preserves an exact retired slug served directly by a Claude custom instance", () => { const withClaudeCustom: ReadonlyArray = [ source("claude_custom", "claudeAgent", ["claude-opus-4-8"], ["claude-opus-4-8"]), diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 43d5bb7ded85..c5f690bce882 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -14,6 +14,15 @@ import { const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); +export const CLAUDE_RETIRED_MODEL_SLUGS: ReadonlySet = new Set(["claude-opus-4-8"]); + +export const CLAUDE_RESERVED_MODEL_SLUGS: ReadonlySet = new Set([ + "claude-fable-5", + "claude-opus-5", + "claude-opus-4-7", + ...CLAUDE_RETIRED_MODEL_SLUGS, +]); + const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< Record>>> > = { @@ -522,6 +531,19 @@ export function pickModelSelectionFromInstances( } } + // An exact legacy slug remains runnable when the native Claude provider + // still reports it (for example, a persisted session or schedule created + // before catalog retirement). Aggregator-only stale entries must not win: + // those continue through the native replacement migration below. + if (migrations.size > 0 && CLAUDE_RETIRED_MODEL_SLUGS.has(trimmed)) { + const directNative = resolveSlug( + trimmed, + () => true, + (source) => source.driverKind === CLAUDE_PROVIDER_DRIVER_KIND, + ); + if (directNative !== null) return directNative; + } + // Preserve an exact provider-owned custom model before migrating a retired // built-in. This is gated to retired inputs so unrelated slug collisions // still use normal native-provider priority. From d9d9f2ef13a83b1bf135e45cc3514fa6f558a5f6 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:07:59 +0100 Subject: [PATCH 14/14] fix: retain options for legacy Claude sessions --- .../src/provider/Layers/ClaudeProvider.ts | 28 ++----------------- .../chat/composerProviderState.test.tsx | 15 ++++++++++ apps/web/src/providerModels.ts | 14 ++++++++-- packages/shared/src/model.ts | 26 +++++++++++++++++ 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 419b13761565..0e901af7511d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + CLAUDE_OPUS_MODEL_CAPABILITIES, CLAUDE_RESERVED_MODEL_SLUGS, createModelCapabilities, getModelSelectionStringOptionValue, @@ -53,29 +54,6 @@ const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const CLAUDE_OPUS_CAPABILITIES = createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultracode", label: "Ultracode" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], -}); - const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-fable-5", @@ -112,7 +90,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ slug: "claude-opus-5", name: "Claude Opus 5", isCustom: false, - capabilities: CLAUDE_OPUS_CAPABILITIES, + capabilities: CLAUDE_OPUS_MODEL_CAPABILITIES, }, { slug: "claude-opus-4-7", @@ -374,7 +352,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo // Persisted 4.8 selections remain runnable, but 4.8 is intentionally absent // from the advertised model catalog now that Opus 5 replaces it. if (slug === "claude-opus-4-8") { - return CLAUDE_OPUS_CAPABILITIES; + return CLAUDE_OPUS_MODEL_CAPABILITIES; } return ( BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 7b34b1c7bea1..52ded54144ba 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -189,6 +189,21 @@ describe("getComposerProviderState", () => { }); }); + it("preserves saved options for an established retired Claude Opus session", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("claudeAgent"), + model: "claude-opus-4-8", + models: [], + modelOptions: selections(["effort", "low"], ["fastMode", true]), + }); + + expect(state).toEqual({ + provider: ProviderDriverKind.make("claudeAgent"), + promptEffort: "low", + modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), + }); + }); + it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { const state = getComposerProviderState({ provider: PROVIDER, diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 9715344cba80..d4f686ff752b 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -8,7 +8,12 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; +import { + CLAUDE_OPUS_MODEL_CAPABILITIES, + CLAUDE_RETIRED_MODEL_SLUGS, + createModelCapabilities, + normalizeModelSlug, +} from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -83,7 +88,12 @@ export function getProviderModelCapabilities( provider: ProviderDriverKind, ): ModelCapabilities { const slug = normalizeModelSlug(model, provider); - return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + const liveCapabilities = models.find((candidate) => candidate.slug === slug)?.capabilities; + if (liveCapabilities) return liveCapabilities; + if (slug && provider === "claudeAgent" && CLAUDE_RETIRED_MODEL_SLUGS.has(slug)) { + return CLAUDE_OPUS_MODEL_CAPABILITIES; + } + return EMPTY_CAPABILITIES; } export function getDefaultServerModel( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index c5f690bce882..faf97d3c7dac 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -54,6 +54,32 @@ export function createModelCapabilities(input: { }; } +export const CLAUDE_OPUS_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + { id: "ultracode", label: "Ultracode" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], +}); + function getRawSelectionValueById( selections: ReadonlyArray | null | undefined, id: string,