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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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, ), };