diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 927d1fad9ca5..ffb8307955cc 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* () { @@ -1435,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, @@ -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/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 9df672f54b0e..14edccef3910 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -47,10 +47,33 @@ 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"; +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", @@ -84,31 +107,10 @@ 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: [ - 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", - }), - ], - }), + capabilities: CLAUDE_OPUS_CAPABILITIES, }, { slug: "claude-opus-4-7", @@ -268,12 +270,12 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; -function supportsClaudeFable5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; +function supportsClaudeOpus5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; +function supportsClaudeFable5(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; } function supportsClaudeOpus47(version: string | null | undefined): boolean { @@ -284,12 +286,12 @@ 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); } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } if (model.slug === "claude-opus-4-7") { return supportsClaudeOpus47(version); } @@ -297,14 +299,14 @@ function getBuiltInClaudeModelsForVersion( }); } -function formatClaudeFable5UpgradeMessage(version: string | null): string { +function formatClaudeOpus5UpgradeMessage(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.`; + 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 formatClaudeOpus48UpgradeMessage(version: string | null): string { +function formatClaudeFable5UpgradeMessage(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.`; + 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 formatClaudeOpus47UpgradeMessage(version: string | null): string { @@ -314,6 +316,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 @@ -356,6 +363,7 @@ export function normalizeClaudeCliEffort( if ( effort === "xhigh" && model !== "claude-fable-5" && + model !== "claude-opus-5" && model !== "claude-opus-4-8" && model !== "claude-sonnet-5" ) { @@ -704,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, ); @@ -772,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, @@ -782,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, @@ -793,17 +806,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); - const versionUpgradeMessage = supportsClaudeFable5(parsedVersion) + const models = versionedModels; + const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities @@ -862,7 +871,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 159d853121c3..9c833cd7c610 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 } 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"; @@ -563,6 +567,121 @@ 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"], + ); + + 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"], + ); + + 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", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -1534,6 +1653,97 @@ 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( + defaultClaudeSettings, + 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) => { + 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/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e538301..42f8ec323d98 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, @@ -78,16 +81,29 @@ 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 mergeProviderModels = ( + driver: ProviderDriverKind, previousModels: ReadonlyArray, nextModels: ReadonlyArray, + preserveMissingBuiltIns: boolean, ): ReadonlyArray => { - if (nextModels.length === 0 && previousModels.length > 0) { - return previousModels; + const retainedPreviousModels = previousModels.filter( + (model) => !isRetiredBuiltInProviderModel(driver, model), + ); + const activeNextModels = nextModels.filter( + (model) => !isRetiredBuiltInProviderModel(driver, model), + ); + + if (nextModels.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,19 +113,31 @@ 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) && + (driver !== CLAUDE_DRIVER_KIND || model.isCustom || preserveMissingBuiltIns), + ), + ]; }; export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, ): ServerProvider => - !previousProvider + !previousProvider || previousProvider.driver !== nextProvider.driver ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels( + nextProvider.driver, + previousProvider.models, + nextProvider.models, + nextProvider.driver === CLAUDE_DRIVER_KIND && nextProvider.version === null, + ), }; export const mergeProviderSnapshots = ( 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, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index c2bfdefda2cd..920ae0543000 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -176,9 +176,12 @@ 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", "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..e3d23b366dbf 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-4-8"); 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