authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 14:38:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 14:38:35-07:00
log7b57b723d3b7490b50fffbd99fe89c6aa98fae1f
tree8cb1010bdf871478d7b32f9d59a6a79da8142d0b
parent9b0df1358f9f9c3277bcb33777c2435b88ee75c4
signature Signed by PGP key B5690EEEBB952194

feat(models): tell users when a CLI update unlocks a new model (#17307)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

17 files changed, 416 insertions(+), 6 deletions(-)

apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+23-1
...@@ -90,6 +90,7 @@ import {...@@ -90,6 +90,7 @@ import {
90 providerSectionIsCollapsed,90 providerSectionIsCollapsed,
91 toggleModelFavorite,91 toggleModelFavorite,
92} from "./thread-settings-sheet-state";92} from "./thread-settings-sheet-state";
93import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
9394
94/**95/**
95 * Everyday harnesses start expanded; every other provider (OpenRouter catalogs96 * Everyday harnesses start expanded; every other provider (OpenRouter catalogs
...@@ -565,6 +566,11 @@ type ThreadSettingsCatalogItem =...@@ -565,6 +566,11 @@ type ThreadSettingsCatalogItem =
565 readonly isFirst: boolean;566 readonly isFirst: boolean;
566 readonly isLast: boolean;567 readonly isLast: boolean;
567 }568 }
569 | {
570 readonly kind: "notice";
571 readonly key: string;
572 readonly text: string;
573 }
568 | {574 | {
569 readonly kind: "empty";575 readonly kind: "empty";
570 readonly key: "empty";576 readonly key: "empty";
...@@ -657,7 +663,12 @@ function useThreadSettingsCatalogItems(...@@ -657,7 +663,12 @@ function useThreadSettingsCatalogItems(
657 ),663 ),
658 session.favoriteKeys,664 session.favoriteKeys,
659 );665 );
660 if (visibleModels.length === 0) {666 // Favorites list only selectable models, so it never explains gated ones.
667 const updateRequiredNotice =
668 group.updateRequired && session.providerFilter !== FAVORITES_PROVIDER_FILTER
669 ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery)
670 : null;
671 if (visibleModels.length === 0 && !updateRequiredNotice) {
661 return [];672 return [];
662 }673 }
663 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);674 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
...@@ -695,6 +706,15 @@ function useThreadSettingsCatalogItems(...@@ -695,6 +706,15 @@ function useThreadSettingsCatalogItems(
695 isFirst: index === 0,706 isFirst: index === 0,
696 isLast: index === provider.models.length - 1,707 isLast: index === provider.models.length - 1,
697 })),708 })),
709 ...(!collapsed && updateRequiredNotice
710 ? [
711 {
712 kind: "notice" as const,
713 key: `notice:${group.providerKey}`,
714 text: updateRequiredNotice,
715 },
716 ]
717 : []),
698 ];718 ];
699 }),719 }),
700 [720 [
...@@ -853,6 +873,8 @@ function ThreadSettingsMainContent(props: {...@@ -853,6 +873,8 @@ function ThreadSettingsMainContent(props: {
853 option={item.option}873 option={item.option}
854 />874 />
855 );875 );
876 } else if (item.kind === "notice") {
877 content = <Text className="mx-8 mt-2 text-xs text-foreground-muted">{item.text}</Text>;
856 } else if (item.kind === "empty") {878 } else if (item.kind === "empty") {
857 content = (879 content = (
858 <View className="items-center px-8 py-14">880 <View className="items-center px-8 py-14">
apps/mobile/src/lib/modelOptions.ts+25-2
...@@ -24,12 +24,20 @@ export type ModelOption = {...@@ -24,12 +24,20 @@ export type ModelOption = {
24 readonly isUnavailable?: boolean;24 readonly isUnavailable?: boolean;
25 readonly capabilities: ModelCapabilities | null;25 readonly capabilities: ModelCapabilities | null;
26 readonly selection: ModelSelection;26 readonly selection: ModelSelection;
27 readonly providerUpdateRequired?: ProviderUpdateRequired;
27};28};
2829
30type ProviderUpdateRequired = Pick<
31 T3ServerConfig["providers"][number],
32 "driver" | "updateRequiredModels"
33>;
34
29export type ProviderGroup = {35export type ProviderGroup = {
30 readonly providerKey: string;36 readonly providerKey: string;
31 readonly providerLabel: string;37 readonly providerLabel: string;
32 readonly models: ReadonlyArray<ModelOption>;38 readonly models: ReadonlyArray<ModelOption>;
39 /** The provider fields that name announced models its CLI is too old to run. */
40 readonly updateRequired?: ProviderUpdateRequired;
33};41};
3442
35function providerDisplayLabel(provider: {43function providerDisplayLabel(provider: {
...@@ -174,6 +182,9 @@ export function buildModelOptions(...@@ -174,6 +182,9 @@ export function buildModelOptions(
174 }182 }
175183
176 const providerLabel = providerDisplayLabel(provider);184 const providerLabel = providerDisplayLabel(provider);
185 const updateRequired = provider.updateRequiredModels?.length
186 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
187 : undefined;
177 for (const model of provider.models) {188 for (const model of provider.models) {
178 const key = `${provider.instanceId}:${model.slug}`;189 const key = `${provider.instanceId}:${model.slug}`;
179 options.set(key, {190 options.set(key, {
...@@ -189,6 +200,7 @@ export function buildModelOptions(...@@ -189,6 +200,7 @@ export function buildModelOptions(
189 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),200 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
190 isDefault: model.isDefault === true,201 isDefault: model.isDefault === true,
191 isLegacy: model.isLegacy === true,202 isLegacy: model.isLegacy === true,
203 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
192 capabilities: model.capabilities,204 capabilities: model.capabilities,
193 selection: normalizeSelectionOptions(205 selection: normalizeSelectionOptions(
194 {206 {
...@@ -239,7 +251,8 @@ export function buildModelOptions(...@@ -239,7 +251,8 @@ export function buildModelOptions(
239 providerDriver,251 providerDriver,
240 isDefault: false,252 isDefault: false,
241 isLegacy: model?.isLegacy === true,253 isLegacy: model?.isLegacy === true,
242 ...(isModelSelectionUnavailable(config, fallbackModelSelection)254 ...(isModelSelectionUnavailable(config, fallbackModelSelection) ||
255 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
243 ? { isUnavailable: true }256 ? { isUnavailable: true }
244 : {}),257 : {}),
245 capabilities: model?.capabilities ?? null,258 capabilities: model?.capabilities ?? null,
...@@ -252,15 +265,24 @@ export function buildModelOptions(...@@ -252,15 +265,24 @@ export function buildModelOptions(
252}265}
253266
254export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {267export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> {
255 const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>();268 const groups = new Map<
269 string,
270 {
271 providerLabel: string;
272 models: ModelOption[];
273 updateRequired: ProviderUpdateRequired | undefined;
274 }
275 >();
256 for (const option of options) {276 for (const option of options) {
257 const existing = groups.get(option.providerKey);277 const existing = groups.get(option.providerKey);
258 if (existing) {278 if (existing) {
259 existing.models.push(option);279 existing.models.push(option);
280 existing.updateRequired ??= option.providerUpdateRequired;
260 } else {281 } else {
261 groups.set(option.providerKey, {282 groups.set(option.providerKey, {
262 providerLabel: option.providerLabel,283 providerLabel: option.providerLabel,
263 models: [option],284 models: [option],
285 updateRequired: option.providerUpdateRequired,
264 });286 });
265 }287 }
266 }288 }
...@@ -269,6 +291,7 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr...@@ -269,6 +291,7 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
269 providerKey,291 providerKey,
270 providerLabel: group.providerLabel,292 providerLabel: group.providerLabel,
271 models: group.models,293 models: group.models,
294 ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}),
272 }));295 }));
273}296}
274297
apps/server/src/provider/ClaudeModelCatalog.test.ts+5
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 resolveClaudeModelCatalog,12 resolveClaudeModelCatalog,
13 resolveClaudeModelsForVersion,13 resolveClaudeModelsForVersion,
14 resolveClaudeModelSlug,14 resolveClaudeModelSlug,
15 resolveClaudeUpdateRequiredModels,
15 scopeClaudeModelCatalog,16 scopeClaudeModelCatalog,
16} from "./ClaudeModelCatalog.ts";17} from "./ClaudeModelCatalog.ts";
1718
...@@ -126,6 +127,10 @@ describe("Claude model catalog", () => {...@@ -126,6 +127,10 @@ describe("Claude model catalog", () => {
126 resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),127 resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
127 ["claude-synthetic-next"],128 ["claude-synthetic-next"],
128 );129 );
130 assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.1.9"), [
131 { slug: "claude-synthetic-next", name: "Claude Synthetic Next", minVersion: "3.2.0" },
132 ]);
133 assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.2.0"), []);
129 assert.strictEqual(134 assert.strictEqual(
130 formatClaudeVersionUpgradeMessage(catalog, "3.1.9"),135 formatClaudeVersionUpgradeMessage(catalog, "3.1.9"),
131 "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.",136 "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.",
apps/server/src/provider/ClaudeModelCatalog.ts+26
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type ModelSelection,4 type ModelSelection,
5 ProviderDriverKind,5 ProviderDriverKind,
6 type ServerProviderModel,6 type ServerProviderModel,
7 type ServerProviderUpdateRequiredModel,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import * as Option from "effect/Option";9import * as Option from "effect/Option";
9import {10import {
...@@ -167,6 +168,31 @@ export function resolveClaudeModelsForVersion(...@@ -167,6 +168,31 @@ export function resolveClaudeModelsForVersion(
167 .map((entry) => entry.model);168 .map((entry) => entry.model);
168}169}
169170
171/**
172 * Current catalog models the installed Claude Code is too old to run, so the
173 * picker can show them as "update to use" instead of hiding them.
174 */
175export function resolveClaudeUpdateRequiredModels(
176 catalog: ClaudeModelCatalog,
177 version: string | null | undefined,
178): ReadonlyArray<ServerProviderUpdateRequiredModel> {
179 if (!version) return [];
180 return catalog.models.flatMap(({ model, compatibility }) => {
181 const minVersion = compatibility.minVersion;
182 if (model.isLegacy || !minVersion || compareSemverVersions(version, minVersion) >= 0) {
183 return [];
184 }
185 return [
186 {
187 slug: model.slug,
188 name: model.name,
189 ...(model.badge ? { badge: model.badge } : {}),
190 minVersion,
191 },
192 ];
193 });
194}
195
170export function formatClaudeVersionUpgradeMessage(196export function formatClaudeVersionUpgradeMessage(
171 catalog: ClaudeModelCatalog,197 catalog: ClaudeModelCatalog,
172 version: string | null,198 version: string | null,
apps/server/src/provider/ClaudeProvider.ts+4
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49 type ClaudeModelCatalog,49 type ClaudeModelCatalog,
50 formatClaudeVersionUpgradeMessage,50 formatClaudeVersionUpgradeMessage,
51 resolveClaudeModelsForVersion,51 resolveClaudeModelsForVersion,
52 resolveClaudeUpdateRequiredModels,
52} from "./ClaudeModelCatalog.ts";53} from "./ClaudeModelCatalog.ts";
5354
54const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({55const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
...@@ -560,6 +561,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -560,6 +561,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
560 claudeSettings.customModels,561 claudeSettings.customModels,
561 DEFAULT_CLAUDE_MODEL_CAPABILITIES,562 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
562 );563 );
564 const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion);
563 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);565 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
564566
565 const capabilities = resolveCapabilities567 const capabilities = resolveCapabilities
...@@ -575,6 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -575,6 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
575 enabled: claudeSettings.enabled,577 enabled: claudeSettings.enabled,
576 checkedAt,578 checkedAt,
577 models,579 models,
580 updateRequiredModels,
578 slashCommands: dedupedSlashCommands,581 slashCommands: dedupedSlashCommands,
579 skills,582 skills,
580 probe: {583 probe: {
...@@ -612,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -612,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
612 enabled: claudeSettings.enabled,615 enabled: claudeSettings.enabled,
613 checkedAt,616 checkedAt,
614 models,617 models,
618 updateRequiredModels,
615 slashCommands: dedupedSlashCommands,619 slashCommands: dedupedSlashCommands,
616 skills,620 skills,
617 probe: {621 probe: {
apps/server/src/provider/ModelManifest.test.ts+74
...@@ -152,6 +152,80 @@ describe("applyManifestDefault", () => {...@@ -152,6 +152,80 @@ describe("applyManifestDefault", () => {
152 });152 });
153});153});
154154
155describe("applyModelManifest", () => {
156 const manifest: ModelManifest.ModelManifestData = {
157 version: 1,
158 currentModels: {},
159 providers: {
160 codex: {
161 profiles: {},
162 models: [
163 {
164 slug: "gpt-next",
165 name: "GPT Next",
166 status: "current",
167 badge: "new",
168 adapter: { codex: { minVersion: "1.2.0" } },
169 },
170 { slug: "gpt-unversioned", name: "GPT Unversioned", status: "current" },
171 {
172 slug: "gpt-retired",
173 name: "GPT Retired",
174 status: "legacy",
175 adapter: { codex: { minVersion: "1.2.0" } },
176 },
177 ],
178 },
179 },
180 };
181 const draft = (version: string | null, models: ReadonlyArray<ServerProviderModel> = []) => ({
182 enabled: true,
183 installed: true,
184 version,
185 status: "ready" as const,
186 auth: { status: "authenticated" as const },
187 checkedAt: "2026-01-01T00:00:00.000Z",
188 models,
189 slashCommands: [],
190 skills: [],
191 });
192
193 it("names current Codex models that need a newer CLI and are missing from discovery", () => {
194 assert.deepStrictEqual(
195 ModelManifest.applyModelManifest(draft("1.1.9"), manifest, CODEX).updateRequiredModels,
196 [{ slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }],
197 );
198 for (const result of [
199 // The CLI is new enough.
200 ModelManifest.applyModelManifest(draft("1.2.0"), manifest, CODEX),
201 // The CLI already lists the model, even under a qualified slug.
202 ModelManifest.applyModelManifest(
203 draft("1.1.9", [model({ slug: "openai.gpt-next" })]),
204 manifest,
205 CODEX,
206 ),
207 // An unknown version cannot be compared.
208 ModelManifest.applyModelManifest(draft(null), manifest, CODEX),
209 // A qualified manifest slug still matches the discovered family.
210 ModelManifest.applyModelManifest(
211 draft("1.1.9", [model({ slug: "gpt-next" })]),
212 {
213 ...manifest,
214 providers: {
215 codex: {
216 profiles: {},
217 models: [{ ...manifest.providers!.codex!.models[0]!, slug: "openai.gpt-next" }],
218 },
219 },
220 },
221 CODEX,
222 ),
223 ]) {
224 assert.isUndefined(result.updateRequiredModels);
225 }
226 });
227});
228
155describe("resolveProviderCatalog", () => {229describe("resolveProviderCatalog", () => {
156 it("resolves generic model presentation through a reusable profile", () => {230 it("resolves generic model presentation through a reusable profile", () => {
157 const manifest: ModelManifest.ModelManifestData = {231 const manifest: ModelManifest.ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+43-1
...@@ -18,13 +18,16 @@ import {...@@ -18,13 +18,16 @@ import {
18 TrimmedNonEmptyString,18 TrimmedNonEmptyString,
19 type ProviderDriverKind,19 type ProviderDriverKind,
20 type ServerProviderModel,20 type ServerProviderModel,
21 type ServerProviderUpdateRequiredModel,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
22import { codexModelFamily } from "@t3tools/shared/model";23import { codexModelFamily } from "@t3tools/shared/model";
24import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
23import * as Clock from "effect/Clock";25import * as Clock from "effect/Clock";
24import * as Context from "effect/Context";26import * as Context from "effect/Context";
25import * as Effect from "effect/Effect";27import * as Effect from "effect/Effect";
26import * as FileSystem from "effect/FileSystem";28import * as FileSystem from "effect/FileSystem";
27import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";
28import * as Path from "effect/Path";31import * as Path from "effect/Path";
29import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
30import * as Semaphore from "effect/Semaphore";33import * as Semaphore from "effect/Semaphore";
...@@ -233,16 +236,55 @@ export function applyModelManifest(...@@ -233,16 +236,55 @@ export function applyModelManifest(
233 manifest: ModelManifestData,236 manifest: ModelManifestData,
234 driverKind: ProviderDriverKind,237 driverKind: ProviderDriverKind,
235): ServerProviderDraft {238): ServerProviderDraft {
239 const { updateRequiredModels: _previous, ...rest } = draft;
240 const updateRequiredModels =
241 driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : [];
236 return {242 return {
237 ...draft,243 ...rest,
238 models: applyManifestDefault(244 models: applyManifestDefault(
239 classifyModels(draft.models, manifest, driverKind),245 classifyModels(draft.models, manifest, driverKind),
240 manifest,246 manifest,
241 driverKind,247 driverKind,
242 ),248 ),
249 ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}),
243 };250 };
244}251}
245252
253const CodexModelAdapter = Schema.Struct({
254 codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })),
255});
256const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter);
257
258/**
259 * Codex lists only the models its own build knows, so a model released after
260 * the installed CLI never shows up. A current manifest entry with
261 * `adapter.codex.minVersion` names that model, letting the picker say an update
262 * unlocks it instead of leaving users to wonder where it is.
263 */
264function codexUpdateRequiredModels(
265 manifest: ModelManifestData,
266 draft: ServerProviderDraft,
267): ReadonlyArray<ServerProviderUpdateRequiredModel> {
268 const version = draft.version?.replace(/^v/, "");
269 if (!version || parseSemver(version) === null) return [];
270 const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug)));
271 return (manifest.providers?.codex?.models ?? []).flatMap((entry) => {
272 if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return [];
273 const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex
274 ?.minVersion;
275 if (!minVersion || parseSemver(minVersion) === null) return [];
276 if (compareSemverVersions(version, minVersion) >= 0) return [];
277 return [
278 {
279 slug: entry.slug,
280 name: entry.name,
281 ...(entry.badge ? { badge: entry.badge } : {}),
282 minVersion,
283 },
284 ];
285 });
286}
287
246/** The manifest's chat default for `driverKind`, when it names one. */288/** The manifest's chat default for `driverKind`, when it names one. */
247export function manifestDefaultModel(289export function manifestDefaultModel(
248 manifest: ModelManifestData,290 manifest: ModelManifestData,
apps/server/src/provider/ProviderRegistry.test.ts+34
...@@ -782,6 +782,40 @@ it.layer(...@@ -782,6 +782,40 @@ it.layer(
782 );782 );
783 });783 });
784784
785 it("does not bring back models the installed CLI is too old to run", () => {
786 // The pending snapshot lists the whole catalog before the version is known.
787 const pendingProvider = {
788 instanceId: ProviderInstanceId.make("claudeAgent"),
789 driver: ProviderDriverKind.make("claudeAgent"),
790 status: "warning",
791 enabled: true,
792 installed: false,
793 auth: { status: "unknown" },
794 checkedAt: "2026-04-14T00:00:00.000Z",
795 version: null,
796 models: [
797 { slug: "claude-old", name: "Old", isCustom: false, capabilities: null },
798 { slug: "claude-next", name: "Next", isCustom: false, capabilities: null },
799 ],
800 slashCommands: [],
801 skills: [],
802 } as const satisfies ServerProvider;
803 const probedProvider = {
804 ...pendingProvider,
805 status: "ready",
806 installed: true,
807 auth: { status: "authenticated" },
808 version: "1.0.0",
809 models: [pendingProvider.models[0]],
810 updateRequiredModels: [{ slug: "claude-next", name: "Next", minVersion: "1.1.0" }],
811 } satisfies ServerProvider;
812
813 assert.deepStrictEqual(
814 ProviderRegistry.mergeProviderSnapshot(pendingProvider, probedProvider).models,
815 [...probedProvider.models],
816 );
817 });
818
785 it("drops stale ACP Registry models missing from a completed discovery probe", () => {819 it("drops stale ACP Registry models missing from a completed discovery probe", () => {
786 const previousProvider = {820 const previousProvider = {
787 instanceId: ProviderInstanceId.make("acpRegistry_codex"),821 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
apps/server/src/provider/ProviderRegistry.ts+7-1
...@@ -260,7 +260,13 @@ const mergeProviderModels = (...@@ -260,7 +260,13 @@ const mergeProviderModels = (
260 // Custom rows are derived from settings and every snapshot carries the full260 // Custom rows are derived from settings and every snapshot carries the full
261 // current list, so a custom model missing from `nextModels` was removed by261 // current list, so a custom model missing from `nextModels` was removed by
262 // the user and must not be resurrected from the previous snapshot.262 // the user and must not be resurrected from the previous snapshot.
263 const retainablePreviousModels = previousModels.filter((model) => !model.isCustom);263 // A model the installed CLI is too old to run was offered by the pending
264 // snapshot, before the version was known; retaining it would make it
265 // selectable again.
266 const updateRequiredSlugs = new Set(provider.updateRequiredModels?.map((model) => model.slug));
267 const retainablePreviousModels = previousModels.filter(
268 (model) => !model.isCustom && !updateRequiredSlugs.has(model.slug),
269 );
264270
265 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {271 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
266 return retainablePreviousModels;272 return retainablePreviousModels;
apps/server/src/provider/model-manifest.json+19-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "version": 1,2 "version": 1,
3 "updatedAt": "2026-10-08T14:44:57Z",3 "updatedAt": "2026-10-08T21:30:00Z",
4 "compatibility": [4 "compatibility": [
5 {5 {
6 "driver": "codex",6 "driver": "codex",
...@@ -112,6 +112,24 @@...@@ -112,6 +112,24 @@
112 "codex": {112 "codex": {
113 "profiles": {},113 "profiles": {},
114 "models": [114 "models": [
115 {
116 "slug": "gpt-6.1-sol",
117 "name": "GPT-6.1 Sol",
118 "status": "current",
119 "adapter": { "codex": { "minVersion": "0.153.0" } }
120 },
121 {
122 "slug": "gpt-6-astra",
123 "name": "GPT-6 Astra",
124 "status": "current",
125 "adapter": { "codex": { "minVersion": "0.153.0" } }
126 },
127 {
128 "slug": "gpt-6-luna",
129 "name": "GPT-6 Luna",
130 "status": "current",
131 "adapter": { "codex": { "minVersion": "0.155.0" } }
132 },
115 { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" },133 { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" },
116 { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" },134 { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" },
117 { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" },135 { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" },
apps/web/src/components/chat/ModelPickerContent.tsx+34
...@@ -6,6 +6,8 @@ import {...@@ -6,6 +6,8 @@ import {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { resolveSelectableModel } from "@t3tools/shared/model";7import { resolveSelectableModel } from "@t3tools/shared/model";
8import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
9import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
10import { useNavigate } from "@tanstack/react-router";
9import { LegendList, type LegendListRef } from "@legendapp/list/react";11import { LegendList, type LegendListRef } from "@legendapp/list/react";
10import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";12import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
11import { ChevronRightIcon } from "lucide-react";13import { ChevronRightIcon } from "lucide-react";
...@@ -275,6 +277,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -275,6 +277,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
275 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);277 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
276 const keybindings = providedKeybindings ?? serverKeybindings;278 const keybindings = providedKeybindings ?? serverKeybindings;
277 const updateSettings = useUpdateClientSettings();279 const updateSettings = useUpdateClientSettings();
280 const navigate = useNavigate();
278281
279 const focusSearchInput = useCallback(() => {282 const focusSearchInput = useCallback(() => {
280 searchInputRef.current?.focus({ preventScroll: true });283 searchInputRef.current?.focus({ preventScroll: true });
...@@ -588,6 +591,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -588,6 +591,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
588 )591 )
589 : [];592 : [];
590593
594 // Models the manifest announces that this provider's installed CLI is too
595 // old to run. Without this, a new model just seems missing until the user
596 // happens to update the CLI.
597 // A search spans every instance, so it can explain gated matches from any of them.
598 const updateRequiredNotices = (
599 isSearching
600 ? instanceEntries.filter(matchesLockedProvider)
601 : selectedEntry
602 ? [selectedEntry]
603 : []
604 ).flatMap((entry) => {
605 const notice = formatProviderUpdateRequiredNotice(entry.snapshot, searchQuery);
606 return notice ? [{ instanceId: entry.instanceId, notice }] : [];
607 });
608
591 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {609 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
592 setExpandedLegacyInstances((expanded) => {610 setExpandedLegacyInstances((expanded) => {
593 const next = new Set(expanded);611 const next = new Set(expanded);
...@@ -1076,6 +1094,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -1076,6 +1094,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
1076 ) : (1094 ) : (
1077 <ComboboxEmpty className="empty:h-0">No models found</ComboboxEmpty>1095 <ComboboxEmpty className="empty:h-0">No models found</ComboboxEmpty>
1078 )}1096 )}
1097 {updateRequiredNotices.map(({ instanceId, notice }) => (
1098 <p
1099 key={instanceId}
1100 className="shrink-0 border-t border-border/70 px-3 py-2 text-xs leading-snug text-muted-foreground"
1101 >
1102 {notice}{" "}
1103 <InlineButton
1104 onClick={() => {
1105 props.onRequestClose?.();
1106 void navigate({ to: "/settings/providers" });
1107 }}
1108 >
1109 Provider settings
1110 </InlineButton>
1111 </p>
1112 ))}
1079 </div>1113 </div>
1080 </Combobox>1114 </Combobox>
1081 </div>1115 </div>
docs/internals/model-manifest.md+6
...@@ -16,6 +16,12 @@ built-in catalog. Adding a model with an existing capability profile is a JSON...@@ -16,6 +16,12 @@ built-in catalog. Adding a model with an existing capability profile is a JSON
16edit; a new profile is needed only for a new capability combination. Codex still16edit; a new profile is needed only for a new capability combination. Codex still
17gets its model list from its app server.17gets its model list from its app server.
1818
19A model that needs a newer provider CLI should still be announced as soon as it
20ships. Claude entries set `adapter.claudeCode.minVersion`; Codex entries set
21`adapter.codex.minVersion`, since Codex's own `model/list` cannot name models
22released after the installed build. Snapshots report those models in
23`updateRequiredModels`, and the picker tells the user which update unlocks them.
24
19`currentModels.claudeAgent` is the current-model classification overlay for25`currentModels.claudeAgent` is the current-model classification overlay for
20releases that predate catalog discovery; it does not add models to their catalogs.26releases that predate catalog discovery; it does not add models to their catalogs.
21Catalog-aware releases use `providers.claudeAgent.models[].status` instead.27Catalog-aware releases use `providers.claudeAgent.models[].status` instead.
packages/client-runtime/package.json+4
...@@ -99,6 +99,10 @@...@@ -99,6 +99,10 @@
99 "types": "./src/providerSkills.ts",99 "types": "./src/providerSkills.ts",
100 "default": "./src/providerSkills.ts"100 "default": "./src/providerSkills.ts"
101 },101 },
102 "./providerUpdateRequiredModels": {
103 "types": "./src/providerUpdateRequiredModels.ts",
104 "default": "./src/providerUpdateRequiredModels.ts"
105 },
102 "./voice-input": {106 "./voice-input": {
103 "types": "./src/voice-input/index.ts",107 "types": "./src/voice-input/index.ts",
104 "default": "./src/voice-input/index.ts"108 "default": "./src/voice-input/index.ts"
packages/client-runtime/src/providerUpdateRequiredModels.test.ts created+47
...@@ -0,0 +1,47 @@
1import { ProviderDriverKind } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { formatProviderUpdateRequiredNotice } from "./providerUpdateRequiredModels.ts";
5
6describe("formatProviderUpdateRequiredNotice", () => {
7 it("names the CLI and the version that unlocks every listed model", () => {
8 expect(
9 formatProviderUpdateRequiredNotice({
10 driver: ProviderDriverKind.make("claudeAgent"),
11 updateRequiredModels: [
12 { slug: "a", name: "Model A", minVersion: "2.1.9" },
13 { slug: "b", name: "Model B", minVersion: "2.1.10" },
14 ],
15 }),
16 ).toBe("Update Claude Code to v2.1.10 or newer to use Model A and Model B.");
17 expect(
18 formatProviderUpdateRequiredNotice({ driver: ProviderDriverKind.make("codex") }),
19 ).toBeNull();
20 });
21
22 it("names only the gated models a search matches", () => {
23 const provider = {
24 driver: ProviderDriverKind.make("claudeAgent"),
25 updateRequiredModels: [
26 { slug: "claude-a", name: "Model A", minVersion: "2.1.9" },
27 { slug: "claude-b", name: "Model B", minVersion: "2.1.10" },
28 ],
29 };
30 expect(formatProviderUpdateRequiredNotice(provider, " model a ")).toBe(
31 "Update Claude Code to v2.1.9 or newer to use Model A.",
32 );
33 expect(formatProviderUpdateRequiredNotice(provider, "gpt")).toBeNull();
34 });
35
36 it("names the release, not a prerelease of it", () => {
37 expect(
38 formatProviderUpdateRequiredNotice({
39 driver: ProviderDriverKind.make("codex"),
40 updateRequiredModels: [
41 { slug: "a", name: "A", minVersion: "2.1.0-beta" },
42 { slug: "b", name: "B", minVersion: "2.1.0" },
43 ],
44 }),
45 ).toBe("Update the Codex CLI to v2.1.0 or newer to use A and B.");
46 });
47});
packages/client-runtime/src/providerUpdateRequiredModels.ts created+45
...@@ -0,0 +1,45 @@
1import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts";
2import { compareSemverVersions } from "@t3tools/shared/semver";
3
4// Name the thing the user updates; "Claude" alone reads like the app or model.
5const RUNTIME_NAMES: Partial<Record<string, string>> = {
6 claudeAgent: "Claude Code",
7 codex: "the Codex CLI",
8};
9
10function formatModelList(names: ReadonlyArray<string>): string {
11 if (names.length <= 2) return names.join(" and ");
12 return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
13}
14
15/**
16 * Model picker notice for models the manifest announces but the installed
17 * provider is too old to run, e.g. "Update Claude Code to v2.1.300 or newer to
18 * use Claude Opus 6." With a search query, only gated models it matches are
19 * named, so searching for one explains why it is missing. Null when nothing
20 * gated matches.
21 */
22export function formatProviderUpdateRequiredNotice(
23 provider: Pick<ServerProvider, "driver" | "updateRequiredModels">,
24 searchQuery = "",
25): string | null {
26 const query = searchQuery.trim().toLocaleLowerCase();
27 const models = (provider.updateRequiredModels ?? []).filter(
28 (model) =>
29 query.length === 0 ||
30 model.name.toLocaleLowerCase().includes(query) ||
31 model.slug.toLocaleLowerCase().includes(query),
32 );
33 if (models.length === 0) return null;
34 // The highest bar unlocks every listed model.
35 const minVersion = models
36 .map((model) => model.minVersion)
37 .reduce((highest, version) =>
38 compareSemverVersions(version, highest) > 0 ? version : highest,
39 );
40 const providerName =
41 RUNTIME_NAMES[provider.driver] ?? PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver;
42 const version = minVersion.startsWith("v") ? minVersion : `v${minVersion}`;
43 const names = formatModelList(models.map((model) => model.name));
44 return `Update ${providerName} to ${version} or newer to use ${names}.`;
45}
packages/contracts/src/server.ts+15
...@@ -86,6 +86,19 @@ export const ServerProviderModel = Schema.Struct({...@@ -86,6 +86,19 @@ export const ServerProviderModel = Schema.Struct({
86});86});
87export type ServerProviderModel = typeof ServerProviderModel.Type;87export type ServerProviderModel = typeof ServerProviderModel.Type;
8888
89/**
90 * A model the model manifest announces that the installed provider version is
91 * too old to run. It is never selectable; clients show it so users learn that
92 * updating the provider unlocks it.
93 */
94export const ServerProviderUpdateRequiredModel = Schema.Struct({
95 slug: TrimmedNonEmptyString,
96 name: TrimmedNonEmptyString,
97 badge: Schema.optional(Schema.Literal("new")),
98 minVersion: TrimmedNonEmptyString,
99});
100export type ServerProviderUpdateRequiredModel = typeof ServerProviderUpdateRequiredModel.Type;
101
89export const ServerProviderSlashCommandInput = Schema.Struct({102export const ServerProviderSlashCommandInput = Schema.Struct({
90 hint: TrimmedNonEmptyString,103 hint: TrimmedNonEmptyString,
91});104});
...@@ -285,6 +298,8 @@ export const ServerProvider = Schema.Struct({...@@ -285,6 +298,8 @@ export const ServerProvider = Schema.Struct({
285 // Surfaces in the UI alongside the missing-driver affordance.298 // Surfaces in the UI alongside the missing-driver affordance.
286 unavailableReason: Schema.optional(TrimmedNonEmptyString),299 unavailableReason: Schema.optional(TrimmedNonEmptyString),
287 models: Schema.Array(ServerProviderModel),300 models: Schema.Array(ServerProviderModel),
301 // Kept apart from `models` so clients that predate it never offer them.
302 updateRequiredModels: Schema.optionalKey(Schema.Array(ServerProviderUpdateRequiredModel)),
288 slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(303 slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(
289 Schema.withDecodingDefault(Effect.succeed([])),304 Schema.withDecodingDefault(Effect.succeed([])),
290 ),305 ),
packages/provider-core/src/server/snapshotProbe.ts+5
...@@ -8,6 +8,7 @@ import type {...@@ -8,6 +8,7 @@ import type {
8 ServerProviderSkill,8 ServerProviderSkill,
9 ServerProviderSlashCommand,9 ServerProviderSlashCommand,
10 ServerProviderModel,10 ServerProviderModel,
11 ServerProviderUpdateRequiredModel,
11 ServerProviderState,12 ServerProviderState,
12 ServerProviderUsageLimits,13 ServerProviderUsageLimits,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -226,6 +227,7 @@ export function buildServerProvider(input: {...@@ -226,6 +227,7 @@ export function buildServerProvider(input: {
226 enabled: boolean;227 enabled: boolean;
227 checkedAt: string;228 checkedAt: string;
228 models: ReadonlyArray<ServerProviderModel>;229 models: ReadonlyArray<ServerProviderModel>;
230 updateRequiredModels?: ReadonlyArray<ServerProviderUpdateRequiredModel>;
229 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;231 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
230 skills?: ReadonlyArray<ServerProviderSkill>;232 skills?: ReadonlyArray<ServerProviderSkill>;
231 probe: ProviderProbeResult;233 probe: ProviderProbeResult;
...@@ -263,6 +265,9 @@ export function buildServerProvider(input: {...@@ -263,6 +265,9 @@ export function buildServerProvider(input: {
263 checkedAt: input.checkedAt,265 checkedAt: input.checkedAt,
264 ...(input.probe.message ? { message: input.probe.message } : {}),266 ...(input.probe.message ? { message: input.probe.message } : {}),
265 models: input.models,267 models: input.models,
268 ...(input.updateRequiredModels?.length
269 ? { updateRequiredModels: input.updateRequiredModels }
270 : {}),
266 slashCommands: [...(input.slashCommands ?? [])],271 slashCommands: [...(input.slashCommands ?? [])],
267 skills: [...(input.skills ?? [])],272 skills: [...(input.skills ?? [])],
268 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),273 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),