| author | |
| committer | |
| log | 7b57b723d3b7490b50fffbd99fe89c6aa98fae1f |
| tree | 8cb1010bdf871478d7b32f9d59a6a79da8142d0b |
| parent | 9b0df1358f9f9c3277bcb33777c2435b88ee75c4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 93 | import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; | ||
| 93 | 94 | ||
| 94 | /** | 95 | /** |
| 95 | * Everyday harnesses start expanded; every other provider (OpenRouter catalogs | 96 | * 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 | }; |
| 28 | 29 | ||
| 30 | type ProviderUpdateRequired = Pick< | ||
| 31 | T3ServerConfig["providers"][number], | ||
| 32 | "driver" | "updateRequiredModels" | ||
| 33 | >; | ||
| 34 | |||
| 29 | export type ProviderGroup = { | 35 | export 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 | }; |
| 34 | 42 | ||
| 35 | function providerDisplayLabel(provider: { | 43 | function providerDisplayLabel(provider: { |
| ... | @@ -174,6 +182,9 @@ export function buildModelOptions( | ... | @@ -174,6 +182,9 @@ export function buildModelOptions( |
| 174 | } | 182 | } |
| 175 | 183 | ||
| 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 | } |
| 253 | 266 | ||
| 254 | export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> { | 267 | export 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 | } |
| 274 | 297 |
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"; |
| 17 | 18 | ||
| ... | @@ -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"; |
| 8 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 9 | import { | 10 | import { |
| ... | @@ -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 | } |
| 169 | 170 | ||
| 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 | */ | ||
| 175 | export 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 | |||
| 170 | export function formatClaudeVersionUpgradeMessage( | 196 | export 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"; |
| 53 | 54 | ||
| 54 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | 55 | const 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); |
| 564 | 566 | ||
| 565 | const capabilities = resolveCapabilities | 567 | 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 | }); |
| 154 | 154 | ||
| 155 | describe("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 | |||
| 155 | describe("resolveProviderCatalog", () => { | 229 | describe("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"; |
| 22 | import { codexModelFamily } from "@t3tools/shared/model"; | 23 | import { codexModelFamily } from "@t3tools/shared/model"; |
| 24 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | ||
| 23 | import * as Clock from "effect/Clock"; | 25 | import * as Clock from "effect/Clock"; |
| 24 | import * as Context from "effect/Context"; | 26 | import * as Context from "effect/Context"; |
| 25 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| 26 | import * as FileSystem from "effect/FileSystem"; | 28 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Layer from "effect/Layer"; | 29 | import * as Layer from "effect/Layer"; |
| 30 | import * as Option from "effect/Option"; | ||
| 28 | import * as Path from "effect/Path"; | 31 | import * as Path from "effect/Path"; |
| 29 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 30 | import * as Semaphore from "effect/Semaphore"; | 33 | import * 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 | } |
| 245 | 252 | ||
| 253 | const CodexModelAdapter = Schema.Struct({ | ||
| 254 | codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), | ||
| 255 | }); | ||
| 256 | const 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 | */ | ||
| 264 | function 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. */ |
| 247 | export function manifestDefaultModel( | 289 | export 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 | }); |
| 784 | 784 | ||
| 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 full | 260 | // Custom rows are derived from settings and every snapshot carries the full |
| 261 | // current list, so a custom model missing from `nextModels` was removed by | 261 | // 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 | ); | ||
| 264 | 270 | ||
| 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"; |
| 7 | import { resolveSelectableModel } from "@t3tools/shared/model"; | 7 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; | ||
| 10 | import { useNavigate } from "@tanstack/react-router"; | ||
| 9 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 11 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 10 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; | 12 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 11 | import { ChevronRightIcon } from "lucide-react"; | 13 | import { 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(); | ||
| 278 | 281 | ||
| 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 | : []; |
| 590 | 593 | ||
| 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 |
| 16 | edit; a new profile is needed only for a new capability combination. Codex still | 16 | edit; a new profile is needed only for a new capability combination. Codex still |
| 17 | gets its model list from its app server. | 17 | gets its model list from its app server. |
| 18 | 18 | ||
| 19 | A model that needs a newer provider CLI should still be announced as soon as it | ||
| 20 | ships. Claude entries set `adapter.claudeCode.minVersion`; Codex entries set | ||
| 21 | `adapter.codex.minVersion`, since Codex's own `model/list` cannot name models | ||
| 22 | released 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 for | 25 | `currentModels.claudeAgent` is the current-model classification overlay for |
| 20 | releases that predate catalog discovery; it does not add models to their catalogs. | 26 | releases that predate catalog discovery; it does not add models to their catalogs. |
| 21 | Catalog-aware releases use `providers.claudeAgent.models[].status` instead. | 27 | Catalog-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 @@ | ||
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { formatProviderUpdateRequiredNotice } from "./providerUpdateRequiredModels.ts"; | ||
| 5 | |||
| 6 | describe("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 @@ | ||
| 1 | import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 3 | |||
| 4 | // Name the thing the user updates; "Claude" alone reads like the app or model. | ||
| 5 | const RUNTIME_NAMES: Partial<Record<string, string>> = { | ||
| 6 | claudeAgent: "Claude Code", | ||
| 7 | codex: "the Codex CLI", | ||
| 8 | }; | ||
| 9 | |||
| 10 | function 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 | */ | ||
| 22 | export 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 | }); |
| 87 | export type ServerProviderModel = typeof ServerProviderModel.Type; | 87 | export type ServerProviderModel = typeof ServerProviderModel.Type; |
| 88 | 88 | ||
| 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 | */ | ||
| 94 | export const ServerProviderUpdateRequiredModel = Schema.Struct({ | ||
| 95 | slug: TrimmedNonEmptyString, | ||
| 96 | name: TrimmedNonEmptyString, | ||
| 97 | badge: Schema.optional(Schema.Literal("new")), | ||
| 98 | minVersion: TrimmedNonEmptyString, | ||
| 99 | }); | ||
| 100 | export type ServerProviderUpdateRequiredModel = typeof ServerProviderUpdateRequiredModel.Type; | ||
| 101 | |||
| 89 | export const ServerProviderSlashCommandInput = Schema.Struct({ | 102 | export 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 } : {}), |