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 {
9090 providerSectionIsCollapsed,
9191 toggleModelFavorite,
9292} from "./thread-settings-sheet-state";
93import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
9394
9495/**
9596 * Everyday harnesses start expanded; every other provider (OpenRouter catalogs
......@@ -565,6 +566,11 @@ type ThreadSettingsCatalogItem =
565566 readonly isFirst: boolean;
566567 readonly isLast: boolean;
567568 }
569 | {
570 readonly kind: "notice";
571 readonly key: string;
572 readonly text: string;
573 }
568574 | {
569575 readonly kind: "empty";
570576 readonly key: "empty";
......@@ -657,7 +663,12 @@ function useThreadSettingsCatalogItems(
657663 ),
658664 session.favoriteKeys,
659665 );
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) {
661672 return [];
662673 }
663674 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
......@@ -695,6 +706,15 @@ function useThreadSettingsCatalogItems(
695706 isFirst: index === 0,
696707 isLast: index === provider.models.length - 1,
697708 })),
709 ...(!collapsed && updateRequiredNotice
710 ? [
711 {
712 kind: "notice" as const,
713 key: `notice:${group.providerKey}`,
714 text: updateRequiredNotice,
715 },
716 ]
717 : []),
698718 ];
699719 }),
700720 [
......@@ -853,6 +873,8 @@ function ThreadSettingsMainContent(props: {
853873 option={item.option}
854874 />
855875 );
876 } else if (item.kind === "notice") {
877 content = <Text className="mx-8 mt-2 text-xs text-foreground-muted">{item.text}</Text>;
856878 } else if (item.kind === "empty") {
857879 content = (
858880 <View className="items-center px-8 py-14">
apps/mobile/src/lib/modelOptions.ts+25-2
......@@ -24,12 +24,20 @@ export type ModelOption = {
2424 readonly isUnavailable?: boolean;
2525 readonly capabilities: ModelCapabilities | null;
2626 readonly selection: ModelSelection;
27 readonly providerUpdateRequired?: ProviderUpdateRequired;
2728};
2829
30type ProviderUpdateRequired = Pick<
31 T3ServerConfig["providers"][number],
32 "driver" | "updateRequiredModels"
33>;
34
2935export type ProviderGroup = {
3036 readonly providerKey: string;
3137 readonly providerLabel: string;
3238 readonly models: ReadonlyArray<ModelOption>;
39 /** The provider fields that name announced models its CLI is too old to run. */
40 readonly updateRequired?: ProviderUpdateRequired;
3341};
3442
3543function providerDisplayLabel(provider: {
......@@ -174,6 +182,9 @@ export function buildModelOptions(
174182 }
175183
176184 const providerLabel = providerDisplayLabel(provider);
185 const updateRequired = provider.updateRequiredModels?.length
186 ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
187 : undefined;
177188 for (const model of provider.models) {
178189 const key = `${provider.instanceId}:${model.slug}`;
179190 options.set(key, {
......@@ -189,6 +200,7 @@ export function buildModelOptions(
189200 ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
190201 isDefault: model.isDefault === true,
191202 isLegacy: model.isLegacy === true,
203 ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
192204 capabilities: model.capabilities,
193205 selection: normalizeSelectionOptions(
194206 {
......@@ -239,7 +251,8 @@ export function buildModelOptions(
239251 providerDriver,
240252 isDefault: false,
241253 isLegacy: model?.isLegacy === true,
242 ...(isModelSelectionUnavailable(config, fallbackModelSelection)
254 ...(isModelSelectionUnavailable(config, fallbackModelSelection) ||
255 provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model)
243256 ? { isUnavailable: true }
244257 : {}),
245258 capabilities: model?.capabilities ?? null,
......@@ -252,15 +265,24 @@ export function buildModelOptions(
252265}
253266
254267export 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 >();
256276 for (const option of options) {
257277 const existing = groups.get(option.providerKey);
258278 if (existing) {
259279 existing.models.push(option);
280 existing.updateRequired ??= option.providerUpdateRequired;
260281 } else {
261282 groups.set(option.providerKey, {
262283 providerLabel: option.providerLabel,
263284 models: [option],
285 updateRequired: option.providerUpdateRequired,
264286 });
265287 }
266288 }
......@@ -269,6 +291,7 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
269291 providerKey,
270292 providerLabel: group.providerLabel,
271293 models: group.models,
294 ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}),
272295 }));
273296}
274297
apps/server/src/provider/ClaudeModelCatalog.test.ts+5
......@@ -12,6 +12,7 @@ import {
1212 resolveClaudeModelCatalog,
1313 resolveClaudeModelsForVersion,
1414 resolveClaudeModelSlug,
15 resolveClaudeUpdateRequiredModels,
1516 scopeClaudeModelCatalog,
1617} from "./ClaudeModelCatalog.ts";
1718
......@@ -126,6 +127,10 @@ describe("Claude model catalog", () => {
126127 resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
127128 ["claude-synthetic-next"],
128129 );
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"), []);
129134 assert.strictEqual(
130135 formatClaudeVersionUpgradeMessage(catalog, "3.1.9"),
131136 "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 {
44 type ModelSelection,
55 ProviderDriverKind,
66 type ServerProviderModel,
7 type ServerProviderUpdateRequiredModel,
78} from "@t3tools/contracts";
89import * as Option from "effect/Option";
910import {
......@@ -167,6 +168,31 @@ export function resolveClaudeModelsForVersion(
167168 .map((entry) => entry.model);
168169}
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
170196export function formatClaudeVersionUpgradeMessage(
171197 catalog: ClaudeModelCatalog,
172198 version: string | null,
apps/server/src/provider/ClaudeProvider.ts+4
......@@ -49,6 +49,7 @@ import {
4949 type ClaudeModelCatalog,
5050 formatClaudeVersionUpgradeMessage,
5151 resolveClaudeModelsForVersion,
52 resolveClaudeUpdateRequiredModels,
5253} from "./ClaudeModelCatalog.ts";
5354
5455const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
......@@ -560,6 +561,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
560561 claudeSettings.customModels,
561562 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
562563 );
564 const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion);
563565 const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);
564566
565567 const capabilities = resolveCapabilities
......@@ -575,6 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
575577 enabled: claudeSettings.enabled,
576578 checkedAt,
577579 models,
580 updateRequiredModels,
578581 slashCommands: dedupedSlashCommands,
579582 skills,
580583 probe: {
......@@ -612,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
612615 enabled: claudeSettings.enabled,
613616 checkedAt,
614617 models,
618 updateRequiredModels,
615619 slashCommands: dedupedSlashCommands,
616620 skills,
617621 probe: {
apps/server/src/provider/ModelManifest.test.ts+74
......@@ -152,6 +152,80 @@ describe("applyManifestDefault", () => {
152152 });
153153});
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
155229describe("resolveProviderCatalog", () => {
156230 it("resolves generic model presentation through a reusable profile", () => {
157231 const manifest: ModelManifest.ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+43-1
......@@ -18,13 +18,16 @@ import {
1818 TrimmedNonEmptyString,
1919 type ProviderDriverKind,
2020 type ServerProviderModel,
21 type ServerProviderUpdateRequiredModel,
2122} from "@t3tools/contracts";
2223import { codexModelFamily } from "@t3tools/shared/model";
24import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
2325import * as Clock from "effect/Clock";
2426import * as Context from "effect/Context";
2527import * as Effect from "effect/Effect";
2628import * as FileSystem from "effect/FileSystem";
2729import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";
2831import * as Path from "effect/Path";
2932import * as Schema from "effect/Schema";
3033import * as Semaphore from "effect/Semaphore";
......@@ -233,16 +236,55 @@ export function applyModelManifest(
233236 manifest: ModelManifestData,
234237 driverKind: ProviderDriverKind,
235238): ServerProviderDraft {
239 const { updateRequiredModels: _previous, ...rest } = draft;
240 const updateRequiredModels =
241 driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : [];
236242 return {
237 ...draft,
243 ...rest,
238244 models: applyManifestDefault(
239245 classifyModels(draft.models, manifest, driverKind),
240246 manifest,
241247 driverKind,
242248 ),
249 ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}),
243250 };
244251}
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
246288/** The manifest's chat default for `driverKind`, when it names one. */
247289export function manifestDefaultModel(
248290 manifest: ModelManifestData,
apps/server/src/provider/ProviderRegistry.test.ts+34
......@@ -782,6 +782,40 @@ it.layer(
782782 );
783783 });
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
785819 it("drops stale ACP Registry models missing from a completed discovery probe", () => {
786820 const previousProvider = {
787821 instanceId: ProviderInstanceId.make("acpRegistry_codex"),
apps/server/src/provider/ProviderRegistry.ts+7-1
......@@ -260,7 +260,13 @@ const mergeProviderModels = (
260260 // Custom rows are derived from settings and every snapshot carries the full
261261 // current list, so a custom model missing from `nextModels` was removed by
262262 // 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
265271 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
266272 return retainablePreviousModels;
apps/server/src/provider/model-manifest.json+19-1
......@@ -1,6 +1,6 @@
11{
22 "version": 1,
3 "updatedAt": "2026-10-08T14:44:57Z",
3 "updatedAt": "2026-10-08T21:30:00Z",
44 "compatibility": [
55 {
66 "driver": "codex",
......@@ -112,6 +112,24 @@
112112 "codex": {
113113 "profiles": {},
114114 "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 },
115133 { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" },
116134 { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" },
117135 { "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 {
66} from "@t3tools/contracts";
77import { resolveSelectableModel } from "@t3tools/shared/model";
88import { useAtomValue } from "@effect/atom-react";
9import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
10import { useNavigate } from "@tanstack/react-router";
911import { LegendList, type LegendListRef } from "@legendapp/list/react";
1012import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
1113import { ChevronRightIcon } from "lucide-react";
......@@ -275,6 +277,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
275277 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
276278 const keybindings = providedKeybindings ?? serverKeybindings;
277279 const updateSettings = useUpdateClientSettings();
280 const navigate = useNavigate();
278281
279282 const focusSearchInput = useCallback(() => {
280283 searchInputRef.current?.focus({ preventScroll: true });
......@@ -588,6 +591,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
588591 )
589592 : [];
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
591609 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
592610 setExpandedLegacyInstances((expanded) => {
593611 const next = new Set(expanded);
......@@ -1076,6 +1094,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
10761094 ) : (
10771095 <ComboboxEmpty className="empty:h-0">No models found</ComboboxEmpty>
10781096 )}
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 ))}
10791113 </div>
10801114 </Combobox>
10811115 </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
1616edit; a new profile is needed only for a new capability combination. Codex still
1717gets 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
1925`currentModels.claudeAgent` is the current-model classification overlay for
2026releases that predate catalog discovery; it does not add models to their catalogs.
2127Catalog-aware releases use `providers.claudeAgent.models[].status` instead.
packages/client-runtime/package.json+4
......@@ -99,6 +99,10 @@
9999 "types": "./src/providerSkills.ts",
100100 "default": "./src/providerSkills.ts"
101101 },
102 "./providerUpdateRequiredModels": {
103 "types": "./src/providerUpdateRequiredModels.ts",
104 "default": "./src/providerUpdateRequiredModels.ts"
105 },
102106 "./voice-input": {
103107 "types": "./src/voice-input/index.ts",
104108 "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({
8686});
8787export 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
89102export const ServerProviderSlashCommandInput = Schema.Struct({
90103 hint: TrimmedNonEmptyString,
91104});
......@@ -285,6 +298,8 @@ export const ServerProvider = Schema.Struct({
285298 // Surfaces in the UI alongside the missing-driver affordance.
286299 unavailableReason: Schema.optional(TrimmedNonEmptyString),
287300 models: Schema.Array(ServerProviderModel),
301 // Kept apart from `models` so clients that predate it never offer them.
302 updateRequiredModels: Schema.optionalKey(Schema.Array(ServerProviderUpdateRequiredModel)),
288303 slashCommands: Schema.Array(ServerProviderSlashCommand).pipe(
289304 Schema.withDecodingDefault(Effect.succeed([])),
290305 ),
packages/provider-core/src/server/snapshotProbe.ts+5
......@@ -8,6 +8,7 @@ import type {
88 ServerProviderSkill,
99 ServerProviderSlashCommand,
1010 ServerProviderModel,
11 ServerProviderUpdateRequiredModel,
1112 ServerProviderState,
1213 ServerProviderUsageLimits,
1314} from "@t3tools/contracts";
......@@ -226,6 +227,7 @@ export function buildServerProvider(input: {
226227 enabled: boolean;
227228 checkedAt: string;
228229 models: ReadonlyArray<ServerProviderModel>;
230 updateRequiredModels?: ReadonlyArray<ServerProviderUpdateRequiredModel>;
229231 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
230232 skills?: ReadonlyArray<ServerProviderSkill>;
231233 probe: ProviderProbeResult;
......@@ -263,6 +265,9 @@ export function buildServerProvider(input: {
263265 checkedAt: input.checkedAt,
264266 ...(input.probe.message ? { message: input.probe.message } : {}),
265267 models: input.models,
268 ...(input.updateRequiredModels?.length
269 ? { updateRequiredModels: input.updateRequiredModels }
270 : {}),
266271 slashCommands: [...(input.slashCommands ?? [])],
267272 skills: [...(input.skills ?? [])],
268273 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),