diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.ts b/apps/server/src/mcp/toolkits/subagent/handlers.ts index f30e929125fe..08b778e2abda 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.ts @@ -472,6 +472,7 @@ const buildModelSources = (runtime: SubagentRuntime) => driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index adfb7ea2a763..811b68cf0447 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -547,6 +547,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, @@ -728,6 +729,7 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" driverKind: providerInstance.driverKind, models: snapshot.models.map((providerModel) => ({ slug: providerModel.slug, + isCustom: providerModel.isCustom, optionDescriptors: providerModel.capabilities?.optionDescriptors, defaultOptions: buildProviderOptionSelectionsFromDescriptors( providerModel.capabilities?.optionDescriptors, diff --git a/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..51d41b0bb9ba 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"; @@ -47,10 +48,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 +108,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 +271,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 +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,56 @@ function getBuiltInClaudeModelsForVersion( }); } -function formatClaudeFable5UpgradeMessage(version: string | null): string { +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 +359,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 +406,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 +755,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 +823,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 +838,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 +849,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 +914,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..013aa24d89e4 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,182 @@ 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("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, + 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..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/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/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index e8fc8a244d02..08dc4fa5d45e 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -134,6 +134,25 @@ describe("instance-scoped model selection", () => { ).toBe("opus"); }); + it("migrates the retired built-in Opus 4.8 selection to Opus 5", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-opus-5", "claude-fable-5"], + }), + ]; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settingsWithProviderInstances(), + providers, + "claude-opus-4-8", + ), + ).toBe("claude-opus-5"); + }); + it("includes Grok custom models from the selected provider instance", () => { const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; const settings: UnifiedSettings = { diff --git a/packages/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..1142194ae17a 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,75 @@ 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 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 +444,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-6", + isCustom: false, defaultOptions: [ { id: "effort", value: "high" }, { id: "fastMode", value: true }, @@ -364,6 +472,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "gpt-5.5", + isCustom: false, defaultOptions: [ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "fast" }, @@ -387,7 +496,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 +513,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-sonnet-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "medium" }], optionDescriptors: [ { @@ -418,6 +529,7 @@ describe("pickModelSelectionFromInstances", () => { }, { slug: "claude-fable-5", + isCustom: false, defaultOptions: [{ id: "effort", value: "xhigh" }], optionDescriptors: [ { @@ -443,9 +555,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 +580,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, @@ -503,6 +616,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [{ id: "contextWindow", value: "1m" }], optionDescriptors: [ { @@ -537,6 +651,7 @@ describe("pickModelSelectionFromInstances", () => { models: [ { slug: "claude-opus-4-8", + isCustom: false, defaultOptions: [ { id: "reasoning", value: "medium" }, { id: "contextWindow", value: "1m" }, @@ -576,7 +691,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..43d5bb7ded85 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,25 @@ import { } from "@t3tools/contracts"; const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); +const CLAUDE_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); + +const RETIRED_SELECTABLE_MODEL_MIGRATIONS: Readonly< + Record>>> +> = { + 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 +310,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 +342,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 +390,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 +443,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 +467,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 +492,52 @@ 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); + } + } + + // 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 +554,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. */