diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 1ff108198c13..88e411328d66 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -90,6 +90,7 @@ import { providerSectionIsCollapsed, toggleModelFavorite, } from "./thread-settings-sheet-state"; +import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; /** * Everyday harnesses start expanded; every other provider (OpenRouter catalogs @@ -565,6 +566,11 @@ type ThreadSettingsCatalogItem = readonly isFirst: boolean; readonly isLast: boolean; } + | { + readonly kind: "notice"; + readonly key: string; + readonly text: string; + } | { readonly kind: "empty"; readonly key: "empty"; @@ -657,7 +663,12 @@ function useThreadSettingsCatalogItems( ), session.favoriteKeys, ); - if (visibleModels.length === 0) { + // Favorites list only selectable models, so it never explains gated ones. + const updateRequiredNotice = + group.updateRequired && session.providerFilter !== FAVORITES_PROVIDER_FILTER + ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery) + : null; + if (visibleModels.length === 0 && !updateRequiredNotice) { return []; } const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); @@ -695,6 +706,15 @@ function useThreadSettingsCatalogItems( isFirst: index === 0, isLast: index === provider.models.length - 1, })), + ...(!collapsed && updateRequiredNotice + ? [ + { + kind: "notice" as const, + key: `notice:${group.providerKey}`, + text: updateRequiredNotice, + }, + ] + : []), ]; }), [ @@ -853,6 +873,8 @@ function ThreadSettingsMainContent(props: { option={item.option} /> ); + } else if (item.kind === "notice") { + content = {item.text}; } else if (item.kind === "empty") { content = ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4469becee1a2..436d6b9996fb 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -24,12 +24,20 @@ export type ModelOption = { readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; + readonly providerUpdateRequired?: ProviderUpdateRequired; }; +type ProviderUpdateRequired = Pick< + T3ServerConfig["providers"][number], + "driver" | "updateRequiredModels" +>; + export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; readonly models: ReadonlyArray; + /** The provider fields that name announced models its CLI is too old to run. */ + readonly updateRequired?: ProviderUpdateRequired; }; function providerDisplayLabel(provider: { @@ -174,6 +182,9 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); + const updateRequired = provider.updateRequiredModels?.length + ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } + : undefined; for (const model of provider.models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { @@ -189,6 +200,7 @@ export function buildModelOptions( ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, + ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -239,7 +251,8 @@ export function buildModelOptions( providerDriver, isDefault: false, isLegacy: model?.isLegacy === true, - ...(isModelSelectionUnavailable(config, fallbackModelSelection) + ...(isModelSelectionUnavailable(config, fallbackModelSelection) || + provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model) ? { isUnavailable: true } : {}), capabilities: model?.capabilities ?? null, @@ -252,15 +265,24 @@ export function buildModelOptions( } export function groupByProvider(options: ReadonlyArray): ReadonlyArray { - const groups = new Map(); + const groups = new Map< + string, + { + providerLabel: string; + models: ModelOption[]; + updateRequired: ProviderUpdateRequired | undefined; + } + >(); for (const option of options) { const existing = groups.get(option.providerKey); if (existing) { existing.models.push(option); + existing.updateRequired ??= option.providerUpdateRequired; } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, models: [option], + updateRequired: option.providerUpdateRequired, }); } } @@ -269,6 +291,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerKey, providerLabel: group.providerLabel, models: group.models, + ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}), })); } diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index c0384e102b8a..5f44c317f0e6 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -12,6 +12,7 @@ import { resolveClaudeModelCatalog, resolveClaudeModelsForVersion, resolveClaudeModelSlug, + resolveClaudeUpdateRequiredModels, scopeClaudeModelCatalog, } from "./ClaudeModelCatalog.ts"; @@ -126,6 +127,10 @@ describe("Claude model catalog", () => { resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), ["claude-synthetic-next"], ); + assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.1.9"), [ + { slug: "claude-synthetic-next", name: "Claude Synthetic Next", minVersion: "3.2.0" }, + ]); + assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.2.0"), []); assert.strictEqual( formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index e601e9dc9392..14d321d792b3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -4,6 +4,7 @@ import { type ModelSelection, ProviderDriverKind, type ServerProviderModel, + type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { @@ -167,6 +168,31 @@ export function resolveClaudeModelsForVersion( .map((entry) => entry.model); } +/** + * Current catalog models the installed Claude Code is too old to run, so the + * picker can show them as "update to use" instead of hiding them. + */ +export function resolveClaudeUpdateRequiredModels( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + if (!version) return []; + return catalog.models.flatMap(({ model, compatibility }) => { + const minVersion = compatibility.minVersion; + if (model.isLegacy || !minVersion || compareSemverVersions(version, minVersion) >= 0) { + return []; + } + return [ + { + slug: model.slug, + name: model.name, + ...(model.badge ? { badge: model.badge } : {}), + minVersion, + }, + ]; + }); +} + export function formatClaudeVersionUpgradeMessage( catalog: ClaudeModelCatalog, version: string | null, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 0770a50f873a..01d731481c23 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -49,6 +49,7 @@ import { type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, + resolveClaudeUpdateRequiredModels, } from "./ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -560,6 +561,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); + const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion); const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities @@ -575,6 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { @@ -612,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 8b8164387f26..666281a8ac5b 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -152,6 +152,80 @@ describe("applyManifestDefault", () => { }); }); +describe("applyModelManifest", () => { + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + codex: { + profiles: {}, + models: [ + { + slug: "gpt-next", + name: "GPT Next", + status: "current", + badge: "new", + adapter: { codex: { minVersion: "1.2.0" } }, + }, + { slug: "gpt-unversioned", name: "GPT Unversioned", status: "current" }, + { + slug: "gpt-retired", + name: "GPT Retired", + status: "legacy", + adapter: { codex: { minVersion: "1.2.0" } }, + }, + ], + }, + }, + }; + const draft = (version: string | null, models: ReadonlyArray = []) => ({ + enabled: true, + installed: true, + version, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models, + slashCommands: [], + skills: [], + }); + + it("names current Codex models that need a newer CLI and are missing from discovery", () => { + assert.deepStrictEqual( + ModelManifest.applyModelManifest(draft("1.1.9"), manifest, CODEX).updateRequiredModels, + [{ slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }], + ); + for (const result of [ + // The CLI is new enough. + ModelManifest.applyModelManifest(draft("1.2.0"), manifest, CODEX), + // The CLI already lists the model, even under a qualified slug. + ModelManifest.applyModelManifest( + draft("1.1.9", [model({ slug: "openai.gpt-next" })]), + manifest, + CODEX, + ), + // An unknown version cannot be compared. + ModelManifest.applyModelManifest(draft(null), manifest, CODEX), + // A qualified manifest slug still matches the discovered family. + ModelManifest.applyModelManifest( + draft("1.1.9", [model({ slug: "gpt-next" })]), + { + ...manifest, + providers: { + codex: { + profiles: {}, + models: [{ ...manifest.providers!.codex!.models[0]!, slug: "openai.gpt-next" }], + }, + }, + }, + CODEX, + ), + ]) { + assert.isUndefined(result.updateRequiredModels); + } + }); +}); + describe("resolveProviderCatalog", () => { it("resolves generic model presentation through a reusable profile", () => { const manifest: ModelManifest.ModelManifestData = { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 402102c93425..2f0f65cf5390 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -18,13 +18,16 @@ import { TrimmedNonEmptyString, type ProviderDriverKind, type ServerProviderModel, + type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; import { codexModelFamily } from "@t3tools/shared/model"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -233,16 +236,55 @@ export function applyModelManifest( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ServerProviderDraft { + const { updateRequiredModels: _previous, ...rest } = draft; + const updateRequiredModels = + driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : []; return { - ...draft, + ...rest, models: applyManifestDefault( classifyModels(draft.models, manifest, driverKind), manifest, driverKind, ), + ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), }; } +const CodexModelAdapter = Schema.Struct({ + codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), +}); +const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); + +/** + * Codex lists only the models its own build knows, so a model released after + * the installed CLI never shows up. A current manifest entry with + * `adapter.codex.minVersion` names that model, letting the picker say an update + * unlocks it instead of leaving users to wonder where it is. + */ +function codexUpdateRequiredModels( + manifest: ModelManifestData, + draft: ServerProviderDraft, +): ReadonlyArray { + const version = draft.version?.replace(/^v/, ""); + if (!version || parseSemver(version) === null) return []; + const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); + return (manifest.providers?.codex?.models ?? []).flatMap((entry) => { + if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; + const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex + ?.minVersion; + if (!minVersion || parseSemver(minVersion) === null) return []; + if (compareSemverVersions(version, minVersion) >= 0) return []; + return [ + { + slug: entry.slug, + name: entry.name, + ...(entry.badge ? { badge: entry.badge } : {}), + minVersion, + }, + ]; + }); +} + /** The manifest's chat default for `driverKind`, when it names one. */ export function manifestDefaultModel( manifest: ModelManifestData, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 11cab0e1d011..733bb59280cf 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -782,6 +782,40 @@ it.layer( ); }); + it("does not bring back models the installed CLI is too old to run", () => { + // The pending snapshot lists the whole catalog before the version is known. + const pendingProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: null, + models: [ + { slug: "claude-old", name: "Old", isCustom: false, capabilities: null }, + { slug: "claude-next", name: "Next", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const probedProvider = { + ...pendingProvider, + status: "ready", + installed: true, + auth: { status: "authenticated" }, + version: "1.0.0", + models: [pendingProvider.models[0]], + updateRequiredModels: [{ slug: "claude-next", name: "Next", minVersion: "1.1.0" }], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(pendingProvider, probedProvider).models, + [...probedProvider.models], + ); + }); + it("drops stale ACP Registry models missing from a completed discovery probe", () => { const previousProvider = { instanceId: ProviderInstanceId.make("acpRegistry_codex"), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a76ad34c8333..5f5120ee28bc 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -260,7 +260,13 @@ const mergeProviderModels = ( // Custom rows are derived from settings and every snapshot carries the full // current list, so a custom model missing from `nextModels` was removed by // the user and must not be resurrected from the previous snapshot. - const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); + // A model the installed CLI is too old to run was offered by the pending + // snapshot, before the version was known; retaining it would make it + // selectable again. + const updateRequiredSlugs = new Set(provider.updateRequiredModels?.map((model) => model.slug)); + const retainablePreviousModels = previousModels.filter( + (model) => !model.isCustom && !updateRequiredSlugs.has(model.slug), + ); if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { return retainablePreviousModels; diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 36b7727d8ecb..943dfbb11fe9 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-08T14:44:57Z", + "updatedAt": "2026-10-08T21:30:00Z", "compatibility": [ { "driver": "codex", @@ -112,6 +112,24 @@ "codex": { "profiles": {}, "models": [ + { + "slug": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-astra", + "name": "GPT-6 Astra", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-luna", + "name": "GPT-6 Luna", + "status": "current", + "adapter": { "codex": { "minVersion": "0.155.0" } } + }, { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 53a2e9c45bac..fb6e79a88162 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -6,6 +6,8 @@ import { } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { useAtomValue } from "@effect/atom-react"; +import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; +import { useNavigate } from "@tanstack/react-router"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon } from "lucide-react"; @@ -275,6 +277,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); + const navigate = useNavigate(); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); @@ -588,6 +591,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ) : []; + // Models the manifest announces that this provider's installed CLI is too + // old to run. Without this, a new model just seems missing until the user + // happens to update the CLI. + // A search spans every instance, so it can explain gated matches from any of them. + const updateRequiredNotices = ( + isSearching + ? instanceEntries.filter(matchesLockedProvider) + : selectedEntry + ? [selectedEntry] + : [] + ).flatMap((entry) => { + const notice = formatProviderUpdateRequiredNotice(entry.snapshot, searchQuery); + return notice ? [{ instanceId: entry.instanceId, notice }] : []; + }); + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { setExpandedLegacyInstances((expanded) => { const next = new Set(expanded); @@ -1076,6 +1094,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ) : ( No models found )} + {updateRequiredNotices.map(({ instanceId, notice }) => ( +

+ {notice}{" "} + { + props.onRequestClose?.(); + void navigate({ to: "/settings/providers" }); + }} + > + Provider settings + +

+ ))} diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md index 7d3eadae7f58..7c2ffa595df6 100644 --- a/docs/internals/model-manifest.md +++ b/docs/internals/model-manifest.md @@ -16,6 +16,12 @@ built-in catalog. Adding a model with an existing capability profile is a JSON edit; a new profile is needed only for a new capability combination. Codex still gets its model list from its app server. +A model that needs a newer provider CLI should still be announced as soon as it +ships. Claude entries set `adapter.claudeCode.minVersion`; Codex entries set +`adapter.codex.minVersion`, since Codex's own `model/list` cannot name models +released after the installed build. Snapshots report those models in +`updateRequiredModels`, and the picker tells the user which update unlocks them. + `currentModels.claudeAgent` is the current-model classification overlay for releases that predate catalog discovery; it does not add models to their catalogs. Catalog-aware releases use `providers.claudeAgent.models[].status` instead. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index e640c5143a21..aea6ead6b943 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -99,6 +99,10 @@ "types": "./src/providerSkills.ts", "default": "./src/providerSkills.ts" }, + "./providerUpdateRequiredModels": { + "types": "./src/providerUpdateRequiredModels.ts", + "default": "./src/providerUpdateRequiredModels.ts" + }, "./voice-input": { "types": "./src/voice-input/index.ts", "default": "./src/voice-input/index.ts" diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts new file mode 100644 index 000000000000..5cdf0c28d3d6 --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts @@ -0,0 +1,47 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatProviderUpdateRequiredNotice } from "./providerUpdateRequiredModels.ts"; + +describe("formatProviderUpdateRequiredNotice", () => { + it("names the CLI and the version that unlocks every listed model", () => { + expect( + formatProviderUpdateRequiredNotice({ + driver: ProviderDriverKind.make("claudeAgent"), + updateRequiredModels: [ + { slug: "a", name: "Model A", minVersion: "2.1.9" }, + { slug: "b", name: "Model B", minVersion: "2.1.10" }, + ], + }), + ).toBe("Update Claude Code to v2.1.10 or newer to use Model A and Model B."); + expect( + formatProviderUpdateRequiredNotice({ driver: ProviderDriverKind.make("codex") }), + ).toBeNull(); + }); + + it("names only the gated models a search matches", () => { + const provider = { + driver: ProviderDriverKind.make("claudeAgent"), + updateRequiredModels: [ + { slug: "claude-a", name: "Model A", minVersion: "2.1.9" }, + { slug: "claude-b", name: "Model B", minVersion: "2.1.10" }, + ], + }; + expect(formatProviderUpdateRequiredNotice(provider, " model a ")).toBe( + "Update Claude Code to v2.1.9 or newer to use Model A.", + ); + expect(formatProviderUpdateRequiredNotice(provider, "gpt")).toBeNull(); + }); + + it("names the release, not a prerelease of it", () => { + expect( + formatProviderUpdateRequiredNotice({ + driver: ProviderDriverKind.make("codex"), + updateRequiredModels: [ + { slug: "a", name: "A", minVersion: "2.1.0-beta" }, + { slug: "b", name: "B", minVersion: "2.1.0" }, + ], + }), + ).toBe("Update the Codex CLI to v2.1.0 or newer to use A and B."); + }); +}); diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.ts b/packages/client-runtime/src/providerUpdateRequiredModels.ts new file mode 100644 index 000000000000..caf44b2bfe53 --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -0,0 +1,45 @@ +import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +// Name the thing the user updates; "Claude" alone reads like the app or model. +const RUNTIME_NAMES: Partial> = { + claudeAgent: "Claude Code", + codex: "the Codex CLI", +}; + +function formatModelList(names: ReadonlyArray): string { + if (names.length <= 2) return names.join(" and "); + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +} + +/** + * Model picker notice for models the manifest announces but the installed + * provider is too old to run, e.g. "Update Claude Code to v2.1.300 or newer to + * use Claude Opus 6." With a search query, only gated models it matches are + * named, so searching for one explains why it is missing. Null when nothing + * gated matches. + */ +export function formatProviderUpdateRequiredNotice( + provider: Pick, + searchQuery = "", +): string | null { + const query = searchQuery.trim().toLocaleLowerCase(); + const models = (provider.updateRequiredModels ?? []).filter( + (model) => + query.length === 0 || + model.name.toLocaleLowerCase().includes(query) || + model.slug.toLocaleLowerCase().includes(query), + ); + if (models.length === 0) return null; + // The highest bar unlocks every listed model. + const minVersion = models + .map((model) => model.minVersion) + .reduce((highest, version) => + compareSemverVersions(version, highest) > 0 ? version : highest, + ); + const providerName = + RUNTIME_NAMES[provider.driver] ?? PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + const version = minVersion.startsWith("v") ? minVersion : `v${minVersion}`; + const names = formatModelList(models.map((model) => model.name)); + return `Update ${providerName} to ${version} or newer to use ${names}.`; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5e33f1aa4c94..e9473dee1df2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -86,6 +86,19 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +/** + * A model the model manifest announces that the installed provider version is + * too old to run. It is never selectable; clients show it so users learn that + * updating the provider unlocks it. + */ +export const ServerProviderUpdateRequiredModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + badge: Schema.optional(Schema.Literal("new")), + minVersion: TrimmedNonEmptyString, +}); +export type ServerProviderUpdateRequiredModel = typeof ServerProviderUpdateRequiredModel.Type; + export const ServerProviderSlashCommandInput = Schema.Struct({ hint: TrimmedNonEmptyString, }); @@ -285,6 +298,8 @@ export const ServerProvider = Schema.Struct({ // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + // Kept apart from `models` so clients that predate it never offer them. + updateRequiredModels: Schema.optionalKey(Schema.Array(ServerProviderUpdateRequiredModel)), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index b29abeed7cc2..1c440a6629e3 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -8,6 +8,7 @@ import type { ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, + ServerProviderUpdateRequiredModel, ServerProviderState, ServerProviderUsageLimits, } from "@t3tools/contracts"; @@ -226,6 +227,7 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + updateRequiredModels?: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; @@ -263,6 +265,9 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + ...(input.updateRequiredModels?.length + ? { updateRequiredModels: input.updateRequiredModels } + : {}), slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),