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/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/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/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/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..419b13761565 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"; @@ -12,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, @@ -47,10 +49,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 +109,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", @@ -267,15 +271,14 @@ 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; } -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; } @@ -284,12 +287,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 +300,68 @@ function getBuiltInClaudeModelsForVersion( }); } -function formatClaudeFable5UpgradeMessage(version: string | null): string { +function providerClaudeModelsFromSettings( + version: string | null | undefined, + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(version), + customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + CLAUDE_RESERVED_MODEL_SLUGS, + ); +} + +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, +): { + readonly snapshot: ServerProvider; + readonly knownModels: ReadonlyArray; +} { + if (!nextSnapshot.installed) { + return { snapshot: nextSnapshot, knownModels: [] }; + } + if (nextSnapshot.version !== null) { + return { + snapshot: nextSnapshot, + knownModels: nextSnapshot.models.filter(isVersionGatedClaudeBuiltInModel), + }; + } + 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 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 +371,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 +418,7 @@ export function normalizeClaudeCliEffort( if ( effort === "xhigh" && model !== "claude-fable-5" && + model !== "claude-opus-5" && model !== "claude-opus-4-8" && model !== "claude-sonnet-5" ) { @@ -703,11 +766,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, - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); + const allModels = providerClaudeModelsFromSettings(null, claudeSettings.customModels); if (!claudeSettings.enabled) { return buildServerProvider({ @@ -772,6 +831,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + const versionedModels = providerClaudeModelsFromSettings( + parsedVersion, + claudeSettings.customModels, + ); if (version.code !== 0) { yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { exitCode: version.code, @@ -782,7 +845,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 +856,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 @@ -861,11 +920,7 @@ export const makePendingClaudeProvider = ( ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - 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 159d853121c3..31004a9463de 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,7 +31,12 @@ 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, + reconcileKnownClaudeModelsAfterVersionProbe, +} from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -563,6 +568,141 @@ 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 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, + ); + 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 +1674,204 @@ 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, + customModels: ["claude-opus-5", "claude-opus-4-8", "my-custom-model"], + }); + 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, + ); + 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, + ); + }), + ); + + 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"), + }, + { + slug: "removed-custom-opus", + name: "Removed Custom Opus", + isCustom: true, + capabilities: getClaudeModelCapabilities("claude-opus-5"), + }, + ] 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); + assert.deepStrictEqual( + learned.knownModels.map((model) => model.slug), + ["claude-opus-5"], + ); + 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( + 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, + 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.", + ); + }).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..aa0dd8f0c59d 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,34 @@ 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.installed && + nextProvider.status === "error" && + 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/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) { 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/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 e8fc8a244d02..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,6 +137,73 @@ describe("instance-scoped model selection", () => { ).toBe("opus"); }); + 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-sonnet-4-6"], + defaultModel: "claude-sonnet-4-6", + }), + ]; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settingsWithProviderInstances(), + providers, + "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", () => { const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; const settings: UnifiedSettings = { 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/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..0c9f1952928c 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,37 @@ 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"); + 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"); + 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(); }); }); @@ -240,11 +272,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, })), @@ -254,7 +288,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 +302,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 +328,11 @@ 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("claude-opus-4-8", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-5", }); expect(pickModelSelectionFromInstances("gpt-5-codex", sources)).toEqual({ instanceId: "codex", @@ -310,6 +348,86 @@ 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", + }); + expect(pickModelSelectionFromInstances("opus", withVersionGatedOpus5)).toEqual({ + 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", + }); + }); + + 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"]), + 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. @@ -337,6 +455,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-6", + isCustom: false, defaultOptions: [ { id: "effort", value: "high" }, { id: "fastMode", value: true }, @@ -364,6 +483,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "gpt-5.5", + isCustom: false, defaultOptions: [ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "fast" }, @@ -387,7 +507,8 @@ describe("pickModelSelectionFromInstances", () => { driverKind: ProviderDriverKind.make("claudeAgent"), models: [ { - slug: "claude-opus-4-8", + slug: "claude-opus-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "high" }], optionDescriptors: [ { @@ -403,6 +524,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-sonnet-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "medium" }], optionDescriptors: [ { @@ -418,6 +540,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-fable-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "xhigh" }], optionDescriptors: [ { @@ -443,9 +566,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({ @@ -468,6 +591,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, @@ -503,6 +627,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [{ id: "contextWindow", value: "1m" }], optionDescriptors: [ { @@ -537,6 +662,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, @@ -576,7 +702,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..c5f690bce882 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,34 @@ import { } from "@t3tools/contracts"; 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>>> +> = { + 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"], + }, +}; + +function resolveRetiredSelectableModelMigrations( + provider: ProviderDriverKind, + value: string, +): ReadonlyArray { + return RETIRED_SELECTABLE_MODEL_MIGRATIONS[provider]?.[value] ?? []; +} export interface SelectableModelOption { slug: string; @@ -291,13 +319,18 @@ export function resolveSelectableModel( return byName.slug; } - const normalized = normalizeModelSlug(trimmed, 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 { @@ -318,6 +351,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; } @@ -365,6 +399,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 +452,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 @@ -441,12 +476,22 @@ 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, + acceptSource: (source: ProviderModelSource) => boolean = () => true, + ): ModelSelection | null => { const best = sources - .filter((source) => source.models.some((entry) => entry.slug === slug)) + .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((candidate) => candidate.slug === slug); + const entry = best.models.find( + (candidate) => candidate.slug === slug && acceptEntry(candidate), + ); return makeModelSelection( best.instanceId, best.driverKind, @@ -456,6 +501,65 @@ export function pickModelSelectionFromInstances( ); }; + 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, acceptEntry, acceptSource); + 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); + } + } + + // 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. + if (migrations.size > 0) { + const directCustom = resolveSlug(trimmed, (entry) => entry.isCustom); + if (directCustom !== null) return directCustom; + } + + 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); if (direct !== null) return direct; @@ -472,20 +576,11 @@ export function pickModelSelectionFromInstances( ? aliases[trimmed] : undefined; if (typeof canonical === "string") canonicals.add(canonical); - } - 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. */