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}