authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 09:15:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 09:15:21-07:00
log2c1915b3193f16e2493709b480e6622645d0ca4e
tree5f957d00266df248107cf8fadb2017bc870a056e
parenta6698271a41a4c1f2c42f25ca5d69cd7d1fa9c30
signature Signed by PGP key B5690EEEBB952194

refactor(provider-core): expose model metadata through a ModelCatalog port (#17417)

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

14 files changed, 579 insertions(+), 449 deletions(-)

apps/server/src/provider/ClaudeModelCatalog.test.ts+12-6
......@@ -1,8 +1,8 @@
11import { assert, describe, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
2import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
33
44import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
5import type { ModelManifestData } from "./ModelManifest.ts";
5import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts";
66import {
77 formatClaudeVersionUpgradeMessage,
88 normalizeClaudeCatalogEffort,
......@@ -71,11 +71,17 @@ const manifest = (): ModelManifestData => ({
7171 },
7272});
7373
74const CLAUDE = ProviderDriverKind.make("claudeAgent");
75
76/** Resolves Claude's catalog the way the driver does: through its manifest catalog entry. */
77const catalogFromManifest = (data: ModelManifestData) =>
78 resolveClaudeModelCatalog(resolveProviderCatalog(data, CLAUDE) ?? undefined);
79
7480describe("Claude model catalog", () => {
7581 it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => {
7682 const source = manifest();
7783 const profile = source.providers!.claudeAgent!.profiles.synthetic!;
78 const catalog = resolveClaudeModelCatalog({
84 const catalog = catalogFromManifest({
7985 ...source,
8086 providers: {
8187 claudeAgent: {
......@@ -121,7 +127,7 @@ describe("Claude model catalog", () => {
121127 });
122128
123129 it("filters models at runtime-version boundaries and derives the upgrade message", () => {
124 const catalog = resolveClaudeModelCatalog(manifest());
130 const catalog = catalogFromManifest(manifest());
125131 assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []);
126132 assert.deepStrictEqual(
127133 resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
......@@ -157,7 +163,7 @@ describe("Claude model catalog", () => {
157163 },
158164 },
159165 };
160 const catalog = resolveClaudeModelCatalog(input);
166 const catalog = catalogFromManifest(input);
161167 assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next");
162168 assert.strictEqual(
163169 resolveClaudeModelSlug(catalog, "claude-synthetic-next"),
......@@ -195,7 +201,7 @@ describe("Claude model catalog", () => {
195201 });
196202
197203 it("appends custom models with their own descriptors and keeps bare slugs opaque", () => {
198 const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [
204 const catalog = scopeClaudeModelCatalog(catalogFromManifest(manifest()), [
199205 "synthetic",
200206 {
201207 slug: "claude-custom-tuned",
apps/server/src/provider/ClaudeModelCatalog.ts+21-12
......@@ -21,11 +21,8 @@ import {
2121 decodeClaudeModelAdapter,
2222 decodeClaudeProfileAdapter,
2323} from "./ClaudeModelManifest.ts";
24import {
25 BUNDLED_MODEL_MANIFEST,
26 type ModelManifestData,
27 resolveProviderCatalog,
28} from "./ModelManifest.ts";
24import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
25import { BUNDLED_MODEL_MANIFEST, resolveProviderCatalog } from "./ModelManifest.ts";
2926
3027const CLAUDE = ProviderDriverKind.make("claudeAgent");
3128const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] };
......@@ -40,8 +37,9 @@ export interface ClaudeModelCatalog {
4037 readonly models: ReadonlyArray<ClaudeCatalogModel>;
4138}
4239
43function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null {
44 const resolved = resolveProviderCatalog(manifest, CLAUDE);
40function tryResolveClaudeModelCatalog(
41 resolved: ModelCatalog.ProviderCatalog | undefined,
42): ClaudeModelCatalog | null {
4543 if (!resolved) return null;
4644
4745 const models: Array<ClaudeCatalogModel> = [];
......@@ -50,7 +48,7 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC
5048 const adapter = decodeClaudeModelAdapter(entry.adapter ?? {});
5149 if (Option.isNone(profile) || Option.isNone(adapter)) return null;
5250 models.push({
53 model: entry.model,
51 model: ModelCatalog.catalogServerModel(resolved, entry),
5452 runtime: profile.value.claudeCode ?? {},
5553 compatibility: adapter.value.claudeCode ?? {},
5654 });
......@@ -61,16 +59,27 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC
6159 };
6260}
6361
64export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog {
62const BUNDLED_CLAUDE_PROVIDER_CATALOG =
63 resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined;
64
65/**
66 * Claude's built-in catalog from its manifest entry, falling back to the
67 * bundled entry when the current one is missing or has invalid adapters.
68 */
69export function resolveClaudeModelCatalog(
70 catalog: ModelCatalog.ProviderCatalog | undefined,
71): ClaudeModelCatalog {
6572 return (
66 tryResolveClaudeModelCatalog(manifest) ??
67 tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? {
73 tryResolveClaudeModelCatalog(catalog) ??
74 tryResolveClaudeModelCatalog(BUNDLED_CLAUDE_PROVIDER_CATALOG) ?? {
6875 models: [],
6976 }
7077 );
7178}
7279
73export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);
80export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(
81 BUNDLED_CLAUDE_PROVIDER_CATALOG,
82);
7483
7584/**
7685 * Scope the catalog to one instance's settings: custom model slugs stay opaque
apps/server/src/provider/Drivers/AntigravityDriver.ts+8-11
......@@ -52,7 +52,7 @@ import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeL
5252import { ProviderDriverError } from "../Errors.ts";
5353import { makeAntigravityProvider } from "../AntigravityProvider.ts";
5454import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
55import * as ModelManifest from "../ModelManifest.ts";
55import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
5656import {
5757 defaultProviderContinuationIdentity,
5858 type ProviderDriver,
......@@ -73,7 +73,7 @@ export type AntigravityDriverEnv =
7373 | Crypto.Crypto
7474 | FileSystem.FileSystem
7575 | IdAllocator.IdAllocatorV2
76 | ModelManifest.ModelManifest
76 | ModelCatalog.ModelCatalog
7777 | Path.Path
7878 | ProviderEventLoggers.ProviderEventLoggers;
7979
......@@ -93,7 +93,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
9393 const selfInvocation = yield* resolveSelfInvocation();
9494 const installation = yield* AntigravityInstallation.AntigravityInstallation;
9595 const loggers = yield* ProviderEventLoggers.ProviderEventLoggers;
96 const modelManifest = yield* ModelManifest.ModelManifest;
96 const modelCatalog = yield* ModelCatalog.ModelCatalog;
97 const currentCatalog = modelCatalog.current(DRIVER);
9798 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
9899 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
99100 const settings = { ...config, enabled } satisfies AntigravitySettings;
......@@ -146,10 +147,8 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
146147 continuationGroupKey: continuationIdentity.continuationKey,
147148 });
148149 const classifyModels = (draft: ServerProviderDraft) =>
149 modelManifest.current.pipe(
150 Effect.map((manifest) =>
151 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)),
152 ),
150 currentCatalog.pipe(
151 Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))),
153152 );
154153
155154 const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* (
......@@ -336,7 +335,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
336335 // and version. The response below is synthetic: only agentInfo.version
337336 // is read from it. Sessions and manual refreshes still spawn.
338337 const probe = Effect.gen(function* () {
339 yield* modelManifest.refreshInBackground;
338 yield* modelCatalog.refreshInBackground;
340339 if (authConfigIssue !== null) {
341340 return yield* new ProviderSetupError({
342341 instanceId,
......@@ -393,9 +392,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
393392 }),
394393 ),
395394 );
396 const defaultModel = modelManifest.current.pipe(
397 Effect.map((manifest) => ModelManifest.manifestDefaultModel(manifest, DRIVER)),
398 );
395 const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel));
399396 const orchestrationAdapter = yield* makeAntigravityAdapterV2({
400397 instanceId,
401398 selfInvocation,
apps/server/src/provider/Drivers/ClaudeDriver.ts+12-12
......@@ -43,7 +43,7 @@ import {
4343 probeClaudeWorkspaceSnapshot,
4444} from "../ClaudeProvider.ts";
4545import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
46import * as ModelManifest from "../ModelManifest.ts";
46import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
4747import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
4848import {
4949 defaultProviderContinuationIdentity,
......@@ -100,7 +100,7 @@ export type ClaudeDriverEnv =
100100 | Crypto.Crypto
101101 | FileSystem.FileSystem
102102 | HttpClient.HttpClient
103 | ModelManifest.ModelManifest
103 | ModelCatalog.ModelCatalog
104104 | Path.Path
105105 | ProviderEventLoggers.ProviderEventLoggers
106106 | ServerConfig.ServerConfig;
......@@ -122,8 +122,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
122122 const httpClient = yield* HttpClient.HttpClient;
123123 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
124124 const host = yield* ProviderHost;
125 const modelManifest = yield* ModelManifest.ModelManifest;
126 const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog));
125 const catalogService = yield* ModelCatalog.ModelCatalog;
126 const modelCatalog = catalogService
127 .current(DRIVER_KIND)
128 .pipe(Effect.map(resolveClaudeModelCatalog));
127129 const processEnv = mergeProviderInstanceEnvironment(environment);
128130 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
129131 driverKind: DRIVER_KIND,
......@@ -208,16 +210,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
208210
209211 // Start the TTL-gated refresh without delaying provider readiness. The
210212 // next check observes a remote manifest after the background fetch lands.
211 const checkProvider = modelManifest.refreshInBackground.pipe(
213 const checkProvider = catalogService.refreshInBackground.pipe(
212214 Effect.andThen(
213 modelManifest.current.pipe(
214 Effect.flatMap((manifest) =>
215 modelCatalog.pipe(
216 Effect.flatMap((catalog) =>
215217 checkClaudeProviderStatus(
216218 effectiveConfig,
217219 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
218220 processEnv,
219221 cwd,
220 resolveClaudeModelCatalog(manifest),
222 catalog,
221223 scopedLimitNames,
222224 (version) =>
223225 ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe(
......@@ -242,10 +244,8 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
242244 streamSettings: snapshotSettings.streamSettings,
243245 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
244246 initialSnapshot: (settings) =>
245 modelManifest.current.pipe(
246 Effect.flatMap((manifest) =>
247 makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)),
248 ),
247 modelCatalog.pipe(
248 Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)),
249249 Effect.map(stampIdentity),
250250 ),
251251 checkProvider,
apps/server/src/provider/Drivers/CodexDriver.ts+10-10
......@@ -49,7 +49,8 @@ import {
4949} from "../CodexProvider.ts";
5050import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
5151import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
52import * as ModelManifest from "../ModelManifest.ts";
52import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
53import { applyCodexModelCatalog } from "../codexModelCatalog.ts";
5354import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver";
5455import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
5556import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
......@@ -114,7 +115,7 @@ export type CodexDriverEnv =
114115 | Crypto.Crypto
115116 | FileSystem.FileSystem
116117 | HttpClient.HttpClient
117 | ModelManifest.ModelManifest
118 | ModelCatalog.ModelCatalog
118119 | Path.Path
119120 | ProviderEventLoggers.ProviderEventLoggers
120121 | ServerConfig.ServerConfig
......@@ -147,7 +148,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
147148 const pathService = yield* Path.Path;
148149 const httpClient = yield* HttpClient.HttpClient;
149150 const host = yield* ProviderHost;
150 const modelManifest = yield* ModelManifest.ModelManifest;
151 const modelCatalog = yield* ModelCatalog.ModelCatalog;
152 const currentCatalog = modelCatalog.current(DRIVER_KIND);
151153 const processEnv = mergeProviderInstanceEnvironment(environment);
152154 const homeLayout = yield* resolveCodexHomeLayout(config);
153155 const continuationIdentity = codexContinuationIdentity(homeLayout);
......@@ -218,13 +220,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
218220 // Kick the TTL-gated manifest refresh in the background and classify
219221 // with the in-memory manifest, so a slow or hung fetch never delays the
220222 // provider check. A refresh that lands mid-probe applies on the next one.
221 const checkProvider = modelManifest.refreshInBackground.pipe(
223 const checkProvider = modelCatalog.refreshInBackground.pipe(
222224 Effect.andThen(
223225 Effect.zipWith(
224226 checkCodexProviderStatus(effectiveConfig, undefined, processEnv),
225 modelManifest.current,
226 (draft, manifest) =>
227 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
227 currentCatalog,
228 (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)),
228229 { concurrent: true },
229230 ),
230231 ),
......@@ -239,9 +240,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
239240 initialSnapshot: (settings) =>
240241 Effect.zipWith(
241242 makePendingCodexProvider(settings.provider),
242 modelManifest.current,
243 (draft, manifest) =>
244 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
243 currentCatalog,
244 (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)),
245245 ),
246246 checkProvider,
247247 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
apps/server/src/provider/ModelManifest.test.ts+8-216
......@@ -1,6 +1,6 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
3import { ProviderDriverKind } from "@t3tools/contracts";
44import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
66import * as Layer from "effect/Layer";
......@@ -20,212 +20,6 @@ import * as ModelManifest from "./ModelManifest.ts";
2020 * synthetic models for resolver coverage.
2121 */
2222
23const CODEX = ProviderDriverKind.make("codex");
24const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
25 slug: "gpt-test",
26 name: "GPT Test",
27 isCustom: false,
28 capabilities: null,
29 ...overrides,
30});
31
32describe("classifyModels", () => {
33 it("classifies qualified Codex families without changing their wire ids", () => {
34 const manifest: ModelManifest.ModelManifestData = {
35 version: 1,
36 currentModels: { codex: ["gpt-test"] },
37 providers: {
38 codex: {
39 profiles: {},
40 models: [{ slug: "gpt-old", name: "Old", status: "legacy" }],
41 },
42 },
43 };
44 const models = [
45 model({ slug: "openai.gpt-test", isLegacy: true }),
46 model({ slug: "openai.gpt-old" }),
47 ];
48 assert.deepStrictEqual(
49 ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [
50 entry.slug,
51 entry.isLegacy ?? false,
52 ]),
53 [
54 ["openai.gpt-test", false],
55 ["openai.gpt-old", true],
56 ],
57 );
58 });
59 it("flags only known legacy models, clears stale flags, and skips custom models", () => {
60 const manifest: ModelManifest.ModelManifestData = {
61 version: 1,
62 currentModels: { codex: ["current-a", "current-b"] },
63 providers: {
64 codex: {
65 profiles: {},
66 models: [{ slug: "old-model", name: "Old", status: "legacy" }],
67 },
68 },
69 };
70 const models = [
71 model({ slug: "current-a" }),
72 // Stale flag from a previous classification pass must be cleared.
73 model({ slug: "current-b", isLegacy: true }),
74 model({ slug: "old-model" }),
75 model({ slug: "new-release", isLegacy: true }),
76 // Custom models are user-defined and never reclassified.
77 model({ slug: "my-own-model", isCustom: true }),
78 ];
79 assert.deepStrictEqual(
80 ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [
81 entry.slug,
82 entry.isLegacy ?? false,
83 ]),
84 [
85 ["current-a", false],
86 ["current-b", false],
87 ["old-model", true],
88 ["new-release", false],
89 ["my-own-model", false],
90 ],
91 );
92 });
93 it.each(["codex", "antigravity"])(
94 "keeps newly discovered %s models current when the manifest has no catalog",
95 (driverKind) => {
96 const models = [model({ slug: "new-release", isLegacy: true })];
97 assert.deepStrictEqual(
98 ModelManifest.classifyModels(
99 models,
100 { version: 1, currentModels: { [driverKind]: ["known-current"] } },
101 ProviderDriverKind.make(driverKind),
102 ),
103 [model({ slug: "new-release" })],
104 );
105 },
106 );
107});
108
109describe("applyManifestDefault", () => {
110 it("resolves the manifest default to the qualified live model", () => {
111 const manifest: ModelManifest.ModelManifestData = {
112 version: 1,
113 currentModels: {},
114 providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } },
115 };
116 const models = [
117 model({ slug: "openai.gpt-old", isDefault: true }),
118 model({ slug: "openai.gpt-test" }),
119 ];
120 assert.strictEqual(
121 ModelManifest.applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault)
122 ?.slug,
123 "openai.gpt-test",
124 );
125 });
126 it("moves the default flag and its aliases to the manifest's chat default", () => {
127 const driver = ProviderDriverKind.make("antigravity");
128 const manifest: ModelManifest.ModelManifestData = {
129 version: 1,
130 currentModels: {},
131 providers: {
132 antigravity: {
133 defaults: { chat: "gemini-new" },
134 profiles: {},
135 models: [{ slug: "gemini-new", name: "New", status: "current" }],
136 },
137 },
138 };
139 const models = [
140 model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }),
141 model({ slug: "gemini-new" }),
142 ];
143 assert.deepStrictEqual(ModelManifest.applyManifestDefault(models, manifest, driver), [
144 model({ slug: "gemini-old" }),
145 model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }),
146 ]);
147 // The account does not offer the manifest default: keep the runtime's choice.
148 assert.deepStrictEqual(
149 ModelManifest.applyManifestDefault(models.slice(0, 1), manifest, driver),
150 models.slice(0, 1),
151 );
152 });
153});
154
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
22923describe("resolveProviderCatalog", () => {
23024 it("resolves generic model presentation through a reusable profile", () => {
23125 const manifest: ModelManifest.ModelManifestData = {
......@@ -268,18 +62,16 @@ describe("resolveProviderCatalog", () => {
26862 ProviderDriverKind.make("synthetic"),
26963 );
27064 assert.deepStrictEqual(catalog?.models[0], {
271 model: {
272 slug: "model-next",
273 name: "Model Next",
274 aliases: ["next"],
275 badge: "new",
276 isCustom: false,
277 isDefault: true,
278 capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!,
279 },
65 slug: "model-next",
66 name: "Model Next",
67 aliases: ["next"],
68 badge: "new",
69 status: "current",
70 capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!,
28071 adapter: undefined,
28172 profileAdapter: { opaque: true },
28273 });
74 assert.strictEqual(catalog?.defaultChatModel, "model-next");
28375 });
28476
28577 it("rejects invalid catalog references", () => {
apps/server/src/provider/ModelManifest.ts+39-177
......@@ -17,17 +17,12 @@ import {
1717 ModelCapabilities,
1818 TrimmedNonEmptyString,
1919 type ProviderDriverKind,
20 type ServerProviderModel,
21 type ServerProviderUpdateRequiredModel,
2220} from "@t3tools/contracts";
23import { codexModelFamily } from "@t3tools/shared/model";
24import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
2521import * as Clock from "effect/Clock";
2622import * as Context from "effect/Context";
2723import * as Effect from "effect/Effect";
2824import * as FileSystem from "effect/FileSystem";
2925import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";
3126import * as Path from "effect/Path";
3227import * as Schema from "effect/Schema";
3328import * as Semaphore from "effect/Semaphore";
......@@ -39,7 +34,7 @@ import * as ServerSettings from "../serverSettings.ts";
3934import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
4035import bundledManifestJson from "./model-manifest.json" with { type: "json" };
4136import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts";
42import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe";
37import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
4338
4439const MODEL_MANIFEST_URL =
4540 "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json";
......@@ -126,19 +121,6 @@ const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe(
126121);
127122export type ModelManifestData = typeof ModelManifestSchema.Type;
128123
129export interface ResolvedManifestModel {
130 readonly model: ServerProviderModel;
131 readonly adapter: unknown;
132 readonly profileAdapter: unknown;
133}
134
135export interface ResolvedProviderCatalog {
136 readonly models: ReadonlyArray<ResolvedManifestModel>;
137 readonly defaults: {
138 readonly chat: string | undefined;
139 };
140}
141
142124const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
143125
144126export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
......@@ -151,16 +133,19 @@ function manifestUpdatedAtMs(manifest: ModelManifestData): number {
151133 return Number.isNaN(parsed) ? 0 : parsed;
152134}
153135
154/** Resolve provider-neutral model presentation and capability data. */
136/**
137 * A driver's catalog entry as provider packages see it, or `null` when the
138 * manifest has none or its references are invalid.
139 */
155140export function resolveProviderCatalog(
156141 manifest: ModelManifestData,
157142 driverKind: ProviderDriverKind,
158): ResolvedProviderCatalog | null {
143): ModelCatalog.ProviderCatalog | null {
159144 const catalog = manifest.providers?.[driverKind];
160145 if (!catalog) return null;
161146
162147 const seen = new Set<string>();
163 const models: Array<ResolvedManifestModel> = [];
148 const models: Array<ModelCatalog.ProviderCatalogModel> = [];
164149 for (const entry of catalog.models) {
165150 if (seen.has(entry.slug)) return null;
166151 seen.add(entry.slug);
......@@ -169,18 +154,14 @@ export function resolveProviderCatalog(
169154 if (entry.profile && !profile) return null;
170155
171156 models.push({
172 model: {
173 slug: entry.slug,
174 name: entry.name,
175 ...(entry.shortName ? { shortName: entry.shortName } : {}),
176 ...(entry.subProvider ? { subProvider: entry.subProvider } : {}),
177 ...(entry.aliases ? { aliases: entry.aliases } : {}),
178 ...(entry.badge ? { badge: entry.badge } : {}),
179 isCustom: false,
180 ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}),
181 ...(entry.status === "legacy" ? { isLegacy: true } : {}),
182 capabilities: profile?.capabilities ?? null,
183 },
157 slug: entry.slug,
158 name: entry.name,
159 ...(entry.shortName ? { shortName: entry.shortName } : {}),
160 ...(entry.subProvider ? { subProvider: entry.subProvider } : {}),
161 ...(entry.aliases ? { aliases: entry.aliases } : {}),
162 ...(entry.badge ? { badge: entry.badge } : {}),
163 status: entry.status,
164 capabilities: profile?.capabilities ?? null,
184165 adapter: entry.adapter,
185166 profileAdapter: profile?.adapter,
186167 });
......@@ -188,12 +169,7 @@ export function resolveProviderCatalog(
188169
189170 if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null;
190171
191 return {
192 models,
193 defaults: {
194 chat: catalog.defaults?.chat,
195 },
196 };
172 return { models, defaultChatModel: catalog.defaults?.chat };
197173}
198174
199175/** On-disk shape of the last successfully fetched manifest. */
......@@ -213,142 +189,6 @@ export const encodeManifestCache = Schema.encodeEffect(
213189 ),
214190);
215191
216/** True when the manifest classifies `slug` as legacy for `driverKind`. */
217function isLegacyModel(
218 manifest: ModelManifestData,
219 driverKind: ProviderDriverKind,
220 slug: string,
221): boolean {
222 const family = driverKind === "codex" ? codexModelFamily(slug) : slug;
223 const catalog = manifest.providers?.[driverKind]?.models;
224 const catalogModel =
225 catalog?.find((model) => model.slug === slug) ??
226 catalog?.find((model) => model.slug === family);
227 return catalogModel?.status === "legacy";
228}
229
230/**
231 * Reclassifies every built-in model on a snapshot draft against the manifest.
232 * Custom models are user-defined and never reclassified.
233 */
234export function applyModelManifest(
235 draft: ServerProviderDraft,
236 manifest: ModelManifestData,
237 driverKind: ProviderDriverKind,
238): ServerProviderDraft {
239 const { updateRequiredModels: _previous, ...rest } = draft;
240 const updateRequiredModels =
241 driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : [];
242 return {
243 ...rest,
244 models: applyManifestDefault(
245 classifyModels(draft.models, manifest, driverKind),
246 manifest,
247 driverKind,
248 ),
249 ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}),
250 };
251}
252
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
288/** The manifest's chat default for `driverKind`, when it names one. */
289export function manifestDefaultModel(
290 manifest: ModelManifestData,
291 driverKind: ProviderDriverKind,
292): string | undefined {
293 return manifest.providers?.[driverKind]?.defaults?.chat;
294}
295
296/**
297 * Moves `isDefault` to the manifest's chat default when the catalog carries
298 * it. Providers that learn their default from the runtime (Antigravity takes
299 * Google's current model) can be overridden here without a release. Aliases
300 * that pointed at the old default move with the flag so the shared
301 * "provider default" alias keeps resolving.
302 */
303export function applyManifestDefault(
304 models: ReadonlyArray<ServerProviderModel>,
305 manifest: ModelManifestData,
306 driverKind: ProviderDriverKind,
307): ReadonlyArray<ServerProviderModel> {
308 const requestedSlug = manifestDefaultModel(manifest, driverKind);
309 if (requestedSlug === undefined) return models;
310 const slug =
311 models.find((model) => model.slug === requestedSlug)?.slug ??
312 (driverKind === "codex"
313 ? models.find(
314 (model) =>
315 !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug),
316 )?.slug
317 : undefined);
318 if (slug === undefined) return models;
319 const previous = models.find((model) => model.isDefault && model.slug !== slug);
320 if (!previous) return models;
321 const movedAliases = previous.aliases ?? [];
322 return models.map((model) => {
323 if (model.slug === previous.slug) {
324 const { isDefault: _isDefault, aliases: _aliases, ...rest } = model;
325 return rest;
326 }
327 if (model.slug === slug) {
328 const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])];
329 return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) };
330 }
331 return model;
332 });
333}
334
335/** Model-level half of `applyModelManifest`, exported for focused tests. */
336export function classifyModels(
337 models: ReadonlyArray<ServerProviderModel>,
338 manifest: ModelManifestData,
339 driverKind: ProviderDriverKind,
340): ReadonlyArray<ServerProviderModel> {
341 return models.map((model) => {
342 if (model.isCustom) return model;
343 if (isLegacyModel(manifest, driverKind, model.slug)) {
344 return model.isLegacy ? model : { ...model, isLegacy: true };
345 }
346 if (!model.isLegacy) return model;
347 const { isLegacy: _isLegacy, ...rest } = model;
348 return rest;
349 });
350}
351
352192export class ModelManifest extends Context.Service<
353193 ModelManifest,
354194 {
......@@ -374,7 +214,7 @@ const BundledOnlyModelManifest: ModelManifest["Service"] = {
374214 refreshInBackground: Effect.void,
375215};
376216
377export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
217const layerBundledOnly = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
378218
379219export const make = Effect.gen(function* () {
380220 const fileSystem = yield* FileSystem.FileSystem;
......@@ -470,3 +310,25 @@ export const make = Effect.gen(function* () {
470310});
471311
472312export const layer = Layer.effect(ModelManifest, make);
313
314/** Bundled data only, with the provider catalog port drivers read; for tests. */
315export const layerTest = Layer.suspend(() =>
316 layerModelCatalog.pipe(Layer.provideMerge(layerBundledOnly)),
317);
318
319/** Exposes the manifest to provider packages as their per-driver catalogs. */
320export const layerModelCatalog = Layer.effect(
321 ModelCatalog.ModelCatalog,
322 Effect.gen(function* () {
323 const manifest = yield* ModelManifest;
324 return ModelCatalog.ModelCatalog.of({
325 current: (driverKind) =>
326 manifest.current.pipe(
327 Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined),
328 ),
329 bundled: (driverKind) =>
330 resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined,
331 refreshInBackground: manifest.refreshInBackground,
332 });
333 }),
334);
apps/server/src/provider/codexModelCatalog.test.ts created+91
......@@ -0,0 +1,91 @@
1import { assert, describe, it } from "@effect/vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
3import type * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
4
5import { applyCodexModelCatalog } from "./codexModelCatalog.ts";
6
7/** Synthetic models only; real Codex model data lives in model-manifest.json. */
8const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
9 slug: "gpt-test",
10 name: "GPT Test",
11 isCustom: false,
12 capabilities: null,
13 ...overrides,
14});
15
16const entry = (
17 overrides: Partial<ModelCatalog.ProviderCatalogModel> & { readonly slug: string },
18): ModelCatalog.ProviderCatalogModel => ({
19 name: overrides.slug,
20 status: "current",
21 capabilities: null,
22 adapter: undefined,
23 profileAdapter: undefined,
24 ...overrides,
25});
26
27const catalog: ModelCatalog.ProviderCatalog = {
28 defaultChatModel: undefined,
29 models: [
30 entry({
31 slug: "gpt-next",
32 name: "GPT Next",
33 badge: "new",
34 adapter: { codex: { minVersion: "1.2.0" } },
35 }),
36 entry({ slug: "gpt-unversioned" }),
37 entry({ slug: "gpt-retired", status: "legacy", adapter: { codex: { minVersion: "1.2.0" } } }),
38 ],
39};
40
41const draft = (version: string | null, models: ReadonlyArray<ServerProviderModel> = []) => ({
42 enabled: true,
43 installed: true,
44 version,
45 status: "ready" as const,
46 auth: { status: "authenticated" as const },
47 checkedAt: "2026-01-01T00:00:00.000Z",
48 models,
49 slashCommands: [],
50 skills: [],
51});
52
53describe("applyCodexModelCatalog", () => {
54 it("names current models that need a newer Codex and are missing from discovery", () => {
55 assert.deepStrictEqual(applyCodexModelCatalog(draft("1.1.9"), catalog).updateRequiredModels, [
56 { slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" },
57 ]);
58 for (const result of [
59 // The CLI is new enough.
60 applyCodexModelCatalog(draft("1.2.0"), catalog),
61 // The CLI already lists the model, even under a qualified slug.
62 applyCodexModelCatalog(draft("1.1.9", [model({ slug: "openai.gpt-next" })]), catalog),
63 // An unknown version cannot be compared.
64 applyCodexModelCatalog(draft(null), catalog),
65 // A qualified catalog slug still matches the discovered family.
66 applyCodexModelCatalog(draft("1.1.9", [model({ slug: "gpt-next" })]), {
67 ...catalog,
68 models: [{ ...catalog.models[0]!, slug: "openai.gpt-next" }],
69 }),
70 ]) {
71 assert.isUndefined(result.updateRequiredModels);
72 }
73 });
74
75 it("classifies qualified Codex families without changing their wire ids", () => {
76 const classified = applyCodexModelCatalog(
77 draft("1.2.0", [
78 model({ slug: "openai.gpt-test", isLegacy: true }),
79 model({ slug: "openai.gpt-retired" }),
80 ]),
81 catalog,
82 );
83 assert.deepStrictEqual(
84 classified.models.map((entry) => [entry.slug, entry.isLegacy ?? false]),
85 [
86 ["openai.gpt-test", false],
87 ["openai.gpt-retired", true],
88 ],
89 );
90 });
91});
apps/server/src/provider/codexModelCatalog.ts created+65
......@@ -0,0 +1,65 @@
1/**
2 * Codex's view of its manifest catalog. Codex discovers models from its app
3 * server, so the catalog only reclassifies them, moves the default, and names
4 * models a newer Codex build would unlock.
5 *
6 * @module provider/codexModelCatalog
7 */
8import type { ServerProviderUpdateRequiredModel } from "@t3tools/contracts";
9import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
10import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe";
11import { codexModelFamily } from "@t3tools/shared/model";
12import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
13import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";
15import { TrimmedNonEmptyString } from "@t3tools/contracts";
16
17/** Dated Codex builds match their base model's catalog entry. */
18const CODEX_MATCHING: ModelCatalog.CatalogMatching = { family: codexModelFamily };
19
20const CodexModelAdapter = Schema.Struct({
21 codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })),
22});
23const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter);
24
25/**
26 * Codex lists only the models its own build knows, so a model released after
27 * the installed CLI never shows up. A current catalog entry with
28 * `adapter.codex.minVersion` names that model, letting the picker say an update
29 * unlocks it instead of leaving users to wonder where it is.
30 */
31function codexUpdateRequiredModels(
32 catalog: ModelCatalog.ProviderCatalog | undefined,
33 draft: ServerProviderDraft,
34): ReadonlyArray<ServerProviderUpdateRequiredModel> {
35 const version = draft.version?.replace(/^v/, "");
36 if (!catalog || !version || parseSemver(version) === null) return [];
37 const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug)));
38 return catalog.models.flatMap((entry) => {
39 if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return [];
40 const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex
41 ?.minVersion;
42 if (!minVersion || parseSemver(minVersion) === null) return [];
43 if (compareSemverVersions(version, minVersion) >= 0) return [];
44 return [
45 {
46 slug: entry.slug,
47 name: entry.name,
48 ...(entry.badge ? { badge: entry.badge } : {}),
49 minVersion,
50 },
51 ];
52 });
53}
54
55/** Reclassifies a Codex snapshot draft's models against its catalog. */
56export function applyCodexModelCatalog(
57 draft: ServerProviderDraft,
58 catalog: ModelCatalog.ProviderCatalog | undefined,
59): ServerProviderDraft {
60 const updateRequiredModels = codexUpdateRequiredModels(catalog, draft);
61 return {
62 ...ModelCatalog.applyModelCatalog(draft, catalog, CODEX_MATCHING),
63 ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}),
64 };
65}
apps/server/src/server.ts+8-4
......@@ -604,11 +604,15 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
604604 // the rewritten telemetry pipeline can account for logical NDJSON writes.
605605 // Provided once at the runtime level so every consumer sees the same
606606 // logger instances.
607 // `ModelManifest.layer` is the legacy-model classification data, refreshed
608 // from the repo's `model-manifest.json` on `main` and applied by the
609 // Codex/Claude drivers.
607 // `ModelManifest.layer` is the model manifest, refreshed from the repo's
608 // `model-manifest.json` on `main`. Drivers read their entry through the
609 // `ModelCatalog` port it provides.
610610 Layer.provideMerge(
611 Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer),
611 Layer.mergeAll(
612 ProviderEventLoggers.layer,
613 ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)),
614 ResetCreditCoordinator.layer,
615 ),
612616 ),
613617 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
614618 // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but
docs/internals/model-manifest.md+4-1
......@@ -11,7 +11,10 @@ correct model data before the next successful fetch. Bump `updatedAt` whenever t
1111file changes. Fetch time cannot establish which copy contains the newer edit.
1212
1313Generic catalog data describes presentation and capabilities. Each provider owns
14its adapter schema and dispatch mappings. Claude uses the manifest for its entire
14its adapter schema and dispatch mappings. Providers never read the manifest
15itself: the server exposes each driver's entry through the
16[`ModelCatalog`](../../packages/provider-core/src/server/ModelCatalog.ts) port,
17and compatibility policies stay with the server. Claude uses the manifest for its entire
1518built-in catalog. Adding a model with an existing capability profile is a JSON
1619edit; a new profile is needed only for a new capability combination. Codex still
1720gets its model list from its app server.
packages/provider-core/package.json+4
......@@ -75,6 +75,10 @@
7575 "types": "./src/server/mcpToolPresentation.ts",
7676 "import": "./src/server/mcpToolPresentation.ts"
7777 },
78 "./server/ModelCatalog": {
79 "types": "./src/server/ModelCatalog.ts",
80 "import": "./src/server/ModelCatalog.ts"
81 },
7882 "./server/nativeProtocolLogging": {
7983 "types": "./src/server/nativeProtocolLogging.ts",
8084 "import": "./src/server/nativeProtocolLogging.ts"
packages/provider-core/src/server/ModelCatalog.test.ts created+126
......@@ -0,0 +1,126 @@
1import { assert, describe, it } from "@effect/vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";
3
4import * as ModelCatalog from "./ModelCatalog.ts";
5
6/**
7 * Covers catalog semantics with synthetic models. Real model data lives in the
8 * server's model-manifest.json and is not asserted here.
9 */
10const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
11 slug: "model-test",
12 name: "Model Test",
13 isCustom: false,
14 capabilities: null,
15 ...overrides,
16});
17
18const catalogModel = (
19 slug: string,
20 status: ModelCatalog.ProviderCatalogModel["status"],
21): ModelCatalog.ProviderCatalogModel => ({
22 slug,
23 name: slug,
24 status,
25 capabilities: null,
26 adapter: undefined,
27 profileAdapter: undefined,
28});
29
30/** Strips a vendor prefix, the way a driver maps qualified slugs to catalog families. */
31const prefixFamily: ModelCatalog.CatalogMatching = {
32 family: (slug) => slug.replace(/^vendor\./, ""),
33};
34
35describe("classifyCatalogModels", () => {
36 it("flags only known legacy models, clears stale flags, and skips custom models", () => {
37 const catalog: ModelCatalog.ProviderCatalog = {
38 models: [catalogModel("old-model", "legacy")],
39 defaultChatModel: undefined,
40 };
41 const models = [
42 model({ slug: "current-a" }),
43 // Stale flag from a previous classification pass must be cleared.
44 model({ slug: "current-b", isLegacy: true }),
45 model({ slug: "old-model" }),
46 // Custom models are user-defined and never reclassified.
47 model({ slug: "my-own-model", isCustom: true, isLegacy: true }),
48 ];
49 assert.deepStrictEqual(
50 ModelCatalog.classifyCatalogModels(models, catalog).map((entry) => [
51 entry.slug,
52 entry.isLegacy ?? false,
53 ]),
54 [
55 ["current-a", false],
56 ["current-b", false],
57 ["old-model", true],
58 ["my-own-model", true],
59 ],
60 );
61 });
62
63 it("matches qualified slugs through the driver's family without changing their ids", () => {
64 const catalog: ModelCatalog.ProviderCatalog = {
65 models: [catalogModel("model-old", "legacy")],
66 defaultChatModel: undefined,
67 };
68 assert.deepStrictEqual(
69 ModelCatalog.classifyCatalogModels(
70 [model({ slug: "vendor.model-test", isLegacy: true }), model({ slug: "vendor.model-old" })],
71 catalog,
72 prefixFamily,
73 ).map((entry) => [entry.slug, entry.isLegacy ?? false]),
74 [
75 ["vendor.model-test", false],
76 ["vendor.model-old", true],
77 ],
78 );
79 });
80
81 it("keeps discovered models current when the driver has no catalog", () => {
82 assert.deepStrictEqual(
83 ModelCatalog.classifyCatalogModels([model({ slug: "new", isLegacy: true })], undefined),
84 [model({ slug: "new" })],
85 );
86 });
87});
88
89describe("applyCatalogDefault", () => {
90 it("moves the default flag and its aliases to the catalog's chat default", () => {
91 const catalog: ModelCatalog.ProviderCatalog = {
92 models: [catalogModel("model-new", "current")],
93 defaultChatModel: "model-new",
94 };
95 const models = [
96 model({ slug: "model-old", isDefault: true, aliases: ["provider-default"] }),
97 model({ slug: "model-new" }),
98 ];
99 assert.deepStrictEqual(ModelCatalog.applyCatalogDefault(models, catalog), [
100 model({ slug: "model-old" }),
101 model({ slug: "model-new", isDefault: true, aliases: ["provider-default"] }),
102 ]);
103 // The account does not offer the catalog default: keep the runtime's choice.
104 assert.deepStrictEqual(
105 ModelCatalog.applyCatalogDefault(models.slice(0, 1), catalog),
106 models.slice(0, 1),
107 );
108 });
109
110 it("resolves the catalog default to a qualified discovered model", () => {
111 const catalog: ModelCatalog.ProviderCatalog = {
112 models: [],
113 defaultChatModel: "model-test",
114 };
115 const models = [
116 model({ slug: "vendor.model-old", isDefault: true }),
117 model({ slug: "vendor.model-test" }),
118 ];
119 assert.strictEqual(
120 ModelCatalog.applyCatalogDefault(models, catalog, prefixFamily).find(
121 (entry) => entry.isDefault,
122 )?.slug,
123 "vendor.model-test",
124 );
125 });
126});
packages/provider-core/src/server/ModelCatalog.ts created+171
......@@ -0,0 +1,171 @@
1/**
2 * ModelCatalog — the model metadata a provider reads from T3's model manifest.
3 *
4 * The server owns the manifest itself (bundle, remote refresh, disk cache,
5 * compatibility policies). A provider only sees its own catalog entry, so a
6 * provider package never depends on the manifest file or how it is fetched.
7 * Reads never wait on the network.
8 *
9 * @module provider-core/server/ModelCatalog
10 */
11import type {
12 ModelCapabilities,
13 ProviderDriverKind,
14 ServerProviderModel,
15} from "@t3tools/contracts";
16import * as Context from "effect/Context";
17import type * as Effect from "effect/Effect";
18
19import type { ServerProviderDraft } from "./snapshotProbe.ts";
20
21/** One model entry in a provider's manifest catalog. */
22export interface ProviderCatalogModel {
23 readonly slug: string;
24 readonly name: string;
25 readonly shortName?: string;
26 readonly subProvider?: string;
27 readonly aliases?: ReadonlyArray<string>;
28 readonly status: "current" | "legacy";
29 readonly badge?: "new";
30 readonly capabilities: ModelCapabilities | null;
31 /** Provider-owned metadata for this model; the provider decodes it. */
32 readonly adapter: unknown;
33 /** Provider-owned metadata for the model's capability profile. */
34 readonly profileAdapter: unknown;
35}
36
37/** A provider's catalog entry: its models and the chat default. */
38export interface ProviderCatalog {
39 readonly models: ReadonlyArray<ProviderCatalogModel>;
40 readonly defaultChatModel: string | undefined;
41}
42
43export class ModelCatalog extends Context.Service<
44 ModelCatalog,
45 {
46 /**
47 * The driver's catalog from the manifest in memory (disk cache or bundle),
48 * or `undefined` when the manifest has no entry for it. Never fetches.
49 */
50 readonly current: (
51 driverKind: ProviderDriverKind,
52 ) => Effect.Effect<ProviderCatalog | undefined>;
53 /** The same catalog from the bundled manifest; for fallbacks when remote data is invalid. */
54 readonly bundled: (driverKind: ProviderDriverKind) => ProviderCatalog | undefined;
55 /**
56 * Starts a TTL-gated remote refresh in the background. Provider checks call
57 * this; it never fails and never blocks the check.
58 */
59 readonly refreshInBackground: Effect.Effect<void>;
60 }
61>()("@t3tools/provider-core/server/ModelCatalog") {}
62
63/** A catalog model as the snapshot model it presents. */
64export const catalogServerModel = (
65 catalog: ProviderCatalog,
66 entry: ProviderCatalogModel,
67): ServerProviderModel => ({
68 slug: entry.slug,
69 name: entry.name,
70 ...(entry.shortName ? { shortName: entry.shortName } : {}),
71 ...(entry.subProvider ? { subProvider: entry.subProvider } : {}),
72 ...(entry.aliases ? { aliases: entry.aliases } : {}),
73 ...(entry.badge ? { badge: entry.badge } : {}),
74 isCustom: false,
75 ...(catalog.defaultChatModel === entry.slug ? { isDefault: true } : {}),
76 ...(entry.status === "legacy" ? { isLegacy: true } : {}),
77 capabilities: entry.capabilities,
78});
79
80/** How a driver matches its discovered slugs to catalog entries. */
81export interface CatalogMatching {
82 /**
83 * Maps a slug to the family the catalog names it by, such as a dated Codex
84 * build to its base model. Defaults to the slug itself.
85 */
86 readonly family?: (slug: string) => string;
87}
88
89const familyOf = (matching: CatalogMatching | undefined, slug: string) =>
90 matching?.family?.(slug) ?? slug;
91
92/**
93 * Reclassifies built-in models as legacy or current from the catalog. Custom
94 * models are user-defined and never reclassified.
95 */
96export function classifyCatalogModels(
97 models: ReadonlyArray<ServerProviderModel>,
98 catalog: ProviderCatalog | undefined,
99 matching?: CatalogMatching,
100): ReadonlyArray<ServerProviderModel> {
101 return models.map((model) => {
102 if (model.isCustom) return model;
103 const entry =
104 catalog?.models.find((candidate) => candidate.slug === model.slug) ??
105 catalog?.models.find((candidate) => candidate.slug === familyOf(matching, model.slug));
106 if (entry?.status === "legacy") return model.isLegacy ? model : { ...model, isLegacy: true };
107 if (!model.isLegacy) return model;
108 const { isLegacy: _isLegacy, ...rest } = model;
109 return rest;
110 });
111}
112
113/**
114 * Moves `isDefault` to the catalog's chat default when the catalog names one
115 * the models include. Providers that learn their default from the runtime can
116 * be overridden here without a release. Aliases that pointed at the old
117 * default move with the flag so the shared "provider default" alias keeps
118 * resolving.
119 */
120export function applyCatalogDefault(
121 models: ReadonlyArray<ServerProviderModel>,
122 catalog: ProviderCatalog | undefined,
123 matching?: CatalogMatching,
124): ReadonlyArray<ServerProviderModel> {
125 const requestedSlug = catalog?.defaultChatModel;
126 if (requestedSlug === undefined) return models;
127 const slug =
128 models.find((model) => model.slug === requestedSlug)?.slug ??
129 (matching?.family === undefined
130 ? undefined
131 : models.find(
132 (model) =>
133 !model.isCustom && familyOf(matching, model.slug) === familyOf(matching, requestedSlug),
134 )?.slug);
135 if (slug === undefined) return models;
136 const previous = models.find((model) => model.isDefault && model.slug !== slug);
137 if (!previous) return models;
138 const movedAliases = previous.aliases ?? [];
139 return models.map((model) => {
140 if (model.slug === previous.slug) {
141 const { isDefault: _isDefault, aliases: _aliases, ...rest } = model;
142 return rest;
143 }
144 if (model.slug === slug) {
145 const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])];
146 return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) };
147 }
148 return model;
149 });
150}
151
152/**
153 * Reclassifies a snapshot draft's built-in models and moves its default from
154 * the catalog. Drivers that announce models a newer CLI unlocks add
155 * `updateRequiredModels` themselves; this clears a stale list.
156 */
157export function applyModelCatalog(
158 draft: ServerProviderDraft,
159 catalog: ProviderCatalog | undefined,
160 matching?: CatalogMatching,
161): ServerProviderDraft {
162 const { updateRequiredModels: _previous, ...rest } = draft;
163 return {
164 ...rest,
165 models: applyCatalogDefault(
166 classifyCatalogModels(draft.models, catalog, matching),
167 catalog,
168 matching,
169 ),
170 };
171}