From d346a43a8f85b567f6290acdd993da9e8fd2c126 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:14:41 -0700 Subject: [PATCH 1/5] feat(models): tell users when a CLI update unlocks a new model When the model manifest announces a model the installed Claude Code or Codex CLI is too old to run, provider snapshots now list it in updateRequiredModels and the web and mobile model pickers say which update unlocks it, instead of the model silently not appearing. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadSettingsSheet.tsx | 16 +++++ apps/mobile/src/lib/modelOptions.ts | 14 ++++- .../src/provider/ClaudeModelCatalog.test.ts | 5 ++ .../server/src/provider/ClaudeModelCatalog.ts | 26 ++++++++ apps/server/src/provider/ClaudeProvider.ts | 4 ++ .../server/src/provider/ModelManifest.test.ts | 60 +++++++++++++++++++ apps/server/src/provider/ModelManifest.ts | 44 +++++++++++++- .../components/chat/ModelPickerContent.tsx | 28 ++++++++- docs/internals/model-manifest.md | 6 ++ packages/client-runtime/package.json | 4 ++ .../src/providerUpdateRequiredModels.test.ts | 21 +++++++ .../src/providerUpdateRequiredModels.ts | 33 ++++++++++ packages/contracts/src/server.ts | 15 +++++ .../provider-core/src/server/snapshotProbe.ts | 5 ++ 14 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/providerUpdateRequiredModels.test.ts create mode 100644 packages/client-runtime/src/providerUpdateRequiredModels.ts diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 1ff108198c13..ce6a9a04795a 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -565,6 +565,11 @@ type ThreadSettingsCatalogItem = readonly isFirst: boolean; readonly isLast: boolean; } + | { + readonly kind: "notice"; + readonly key: string; + readonly text: string; + } | { readonly kind: "empty"; readonly key: "empty"; @@ -695,6 +700,15 @@ function useThreadSettingsCatalogItems( isFirst: index === 0, isLast: index === provider.models.length - 1, })), + ...(!collapsed && group.updateRequiredNotice + ? [ + { + kind: "notice" as const, + key: `notice:${group.providerKey}`, + text: group.updateRequiredNotice, + }, + ] + : []), ]; }), [ @@ -853,6 +867,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..8681d6aebbde 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -9,6 +9,7 @@ import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; export type ModelOption = { readonly key: string; @@ -24,12 +25,15 @@ export type ModelOption = { readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; + readonly providerUpdateRequiredNotice?: string; }; export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; readonly models: ReadonlyArray; + /** Announced models the installed CLI is too old to run. */ + readonly updateRequiredNotice?: string; }; function providerDisplayLabel(provider: { @@ -174,6 +178,7 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); + const updateRequiredNotice = formatProviderUpdateRequiredNotice(provider); for (const model of provider.models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { @@ -189,6 +194,7 @@ export function buildModelOptions( ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, + ...(updateRequiredNotice ? { providerUpdateRequiredNotice: updateRequiredNotice } : {}), capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -252,15 +258,20 @@ export function buildModelOptions( } export function groupByProvider(options: ReadonlyArray): ReadonlyArray { - const groups = new Map(); + const groups = new Map< + string, + { providerLabel: string; models: ModelOption[]; updateRequiredNotice: string | undefined } + >(); for (const option of options) { const existing = groups.get(option.providerKey); if (existing) { existing.models.push(option); + existing.updateRequiredNotice ??= option.providerUpdateRequiredNotice; } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, models: [option], + updateRequiredNotice: option.providerUpdateRequiredNotice, }); } } @@ -269,6 +280,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerKey, providerLabel: group.providerLabel, models: group.models, + ...(group.updateRequiredNotice ? { updateRequiredNotice: group.updateRequiredNotice } : {}), })); } 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..60ffc3b0a8be 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -152,6 +152,66 @@ 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), + ]) { + 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..de6b0ba606db 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(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/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 53a2e9c45bac..0c505360db78 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,14 @@ 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. + const updateRequiredNotice = + !isSearching && selectedEntry + ? formatProviderUpdateRequiredNotice(selectedEntry.snapshot) + : null; + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { setExpandedLegacyInstances((expanded) => { const next = new Set(expanded); @@ -1074,7 +1085,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ))} ) : ( - No models found + <> + No models found + {updateRequiredNotice ? ( +

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

+ ) : null} + )} 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..c612a2e7f50a --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts @@ -0,0 +1,21 @@ +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(); + }); +}); diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.ts b/packages/client-runtime/src/providerUpdateRequiredModels.ts new file mode 100644 index 000000000000..189409e745cf --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -0,0 +1,33 @@ +import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; + +// 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." Null when nothing is gated. + */ +export function formatProviderUpdateRequiredNotice( + provider: Pick, +): string | null { + const models = provider.updateRequiredModels ?? []; + if (models.length === 0) return null; + // The highest bar unlocks every listed model. + const minVersion = models + .map((model) => model.minVersion) + .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))[0]!; + 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 } : {}), From 2af8b3e501eb11f4278e032ad1e47adb495ca833 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:29:47 -0700 Subject: [PATCH 2/5] fix(server): keep update-gated models out of retained snapshot models The pending snapshot lists the full catalog before the CLI version is known, and the registry merge retained those rows after the probe, making models the CLI cannot run selectable again. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ProviderRegistry.test.ts | 34 +++++++++++++++++++ apps/server/src/provider/ProviderRegistry.ts | 8 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) 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; From 430a0e7dc59e6faa3f664352525a2adb6e1dfe73 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:38:30 -0700 Subject: [PATCH 3/5] fix(clients): searching for an update-gated model explains why it is missing The notice was dropped while searching (web) or with its provider section when no selectable model matched (mobile). It now names the gated models a search matches, on every searchable instance. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadSettingsSheet.tsx | 12 ++++++-- apps/mobile/src/lib/modelOptions.ts | 30 ++++++++++++------- .../components/chat/ModelPickerContent.tsx | 26 +++++++++++----- .../src/providerUpdateRequiredModels.test.ts | 14 +++++++++ .../src/providerUpdateRequiredModels.ts | 13 ++++++-- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ce6a9a04795a..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 @@ -662,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); @@ -700,12 +706,12 @@ function useThreadSettingsCatalogItems( isFirst: index === 0, isLast: index === provider.models.length - 1, })), - ...(!collapsed && group.updateRequiredNotice + ...(!collapsed && updateRequiredNotice ? [ { kind: "notice" as const, key: `notice:${group.providerKey}`, - text: group.updateRequiredNotice, + text: updateRequiredNotice, }, ] : []), diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 8681d6aebbde..08ac44592374 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -9,7 +9,6 @@ import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; export type ModelOption = { readonly key: string; @@ -25,15 +24,20 @@ export type ModelOption = { readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; - readonly providerUpdateRequiredNotice?: string; + readonly providerUpdateRequired?: ProviderUpdateRequired; }; +type ProviderUpdateRequired = Pick< + T3ServerConfig["providers"][number], + "driver" | "updateRequiredModels" +>; + export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; readonly models: ReadonlyArray; - /** Announced models the installed CLI is too old to run. */ - readonly updateRequiredNotice?: string; + /** The provider fields that name announced models its CLI is too old to run. */ + readonly updateRequired?: ProviderUpdateRequired; }; function providerDisplayLabel(provider: { @@ -178,7 +182,9 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); - const updateRequiredNotice = formatProviderUpdateRequiredNotice(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, { @@ -194,7 +200,7 @@ export function buildModelOptions( ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, - ...(updateRequiredNotice ? { providerUpdateRequiredNotice: updateRequiredNotice } : {}), + ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -260,18 +266,22 @@ export function buildModelOptions( export function groupByProvider(options: ReadonlyArray): ReadonlyArray { const groups = new Map< string, - { providerLabel: string; models: ModelOption[]; updateRequiredNotice: string | undefined } + { + 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.updateRequiredNotice ??= option.providerUpdateRequiredNotice; + existing.updateRequired ??= option.providerUpdateRequired; } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, models: [option], - updateRequiredNotice: option.providerUpdateRequiredNotice, + updateRequired: option.providerUpdateRequired, }); } } @@ -280,7 +290,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerKey, providerLabel: group.providerLabel, models: group.models, - ...(group.updateRequiredNotice ? { updateRequiredNotice: group.updateRequiredNotice } : {}), + ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}), })); } diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 0c505360db78..6d9ea27c064a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -594,10 +594,17 @@ 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. - const updateRequiredNotice = - !isSearching && selectedEntry - ? formatProviderUpdateRequiredNotice(selectedEntry.snapshot) - : null; + // 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) => { @@ -1087,9 +1094,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ) : ( <> No models found - {updateRequiredNotice ? ( -

- {updateRequiredNotice}{" "} + {updateRequiredNotices.map(({ instanceId, notice }) => ( +

+ {notice}{" "} { props.onRequestClose?.(); @@ -1099,7 +1109,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { Provider settings

- ) : null} + ))} )} diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts index c612a2e7f50a..bd5baa0dec52 100644 --- a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts +++ b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts @@ -18,4 +18,18 @@ describe("formatProviderUpdateRequiredNotice", () => { 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(); + }); }); diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.ts b/packages/client-runtime/src/providerUpdateRequiredModels.ts index 189409e745cf..ecbc45f56a57 100644 --- a/packages/client-runtime/src/providerUpdateRequiredModels.ts +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -14,12 +14,21 @@ function formatModelList(names: ReadonlyArray): string { /** * 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." Null when nothing is gated. + * 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 models = provider.updateRequiredModels ?? []; + 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 From c1ea223fc5314e540ad5ce8370bc2da01a83a201 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:55:26 -0700 Subject: [PATCH 4/5] fix(models): address review on update-gated model notices - Match qualified Codex manifest slugs against discovered families. - Pick the highest required version with semver, not string collation. - Show web notices alongside the provider setup notice. - Mark a remembered mobile selection that now needs an update unavailable. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/modelOptions.ts | 3 +- .../server/src/provider/ModelManifest.test.ts | 14 ++++++++ apps/server/src/provider/ModelManifest.ts | 2 +- .../components/chat/ModelPickerContent.tsx | 36 +++++++++---------- .../src/providerUpdateRequiredModels.test.ts | 12 +++++++ .../src/providerUpdateRequiredModels.ts | 5 ++- 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 08ac44592374..436d6b9996fb 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -251,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, diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 60ffc3b0a8be..666281a8ac5b 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -206,6 +206,20 @@ describe("applyModelManifest", () => { ), // 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); } diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index de6b0ba606db..2f0f65cf5390 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -269,7 +269,7 @@ function codexUpdateRequiredModels( 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(entry.slug)) return []; + 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 []; diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 6d9ea27c064a..fb6e79a88162 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1092,26 +1092,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ))} ) : ( - <> - No models found - {updateRequiredNotices.map(({ instanceId, notice }) => ( -

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

- ))} - + No models found )} + {updateRequiredNotices.map(({ instanceId, notice }) => ( +

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

+ ))} diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts index bd5baa0dec52..5cdf0c28d3d6 100644 --- a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts +++ b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts @@ -32,4 +32,16 @@ describe("formatProviderUpdateRequiredNotice", () => { ); 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 index ecbc45f56a57..caf44b2bfe53 100644 --- a/packages/client-runtime/src/providerUpdateRequiredModels.ts +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -1,4 +1,5 @@ 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> = { @@ -33,7 +34,9 @@ export function formatProviderUpdateRequiredNotice( // The highest bar unlocks every listed model. const minVersion = models .map((model) => model.minVersion) - .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))[0]!; + .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}`; From da8ec9ed96b8f8b8b3628cf7f2411928c3aeed34 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:26:22 -0700 Subject: [PATCH 5/5] feat(models): gate current Codex models on their minimum CLI version Versions come from minimal_client_version in Codex's bundled catalog (codex-rs/models-manager/models.json): GPT-6.1 Sol and GPT-6 Astra need 0.153.0, GPT-6 Luna needs 0.155.0. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/model-manifest.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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" },