+ ))}
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 } : {}),