| 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 | */ |
| 11 | import type { |
| 12 | ModelCapabilities, |
| 13 | ProviderDriverKind, |
| 14 | ServerProviderModel, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import * as Context from "effect/Context"; |
| 17 | import type * as Effect from "effect/Effect"; |
| 18 | |
| 19 | import type { ServerProviderDraft } from "./snapshotProbe.ts"; |
| 20 | |
| 21 | /** One model entry in a provider's manifest catalog. */ |
| 22 | export 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. */ |
| 38 | export interface ProviderCatalog { |
| 39 | readonly models: ReadonlyArray<ProviderCatalogModel>; |
| 40 | readonly defaultChatModel: string | undefined; |
| 41 | } |
| 42 | |
| 43 | export 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. */ |
| 64 | export 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. */ |
| 81 | export 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 | |
| 89 | const 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 | */ |
| 96 | export 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 | */ |
| 120 | export 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 | */ |
| 157 | export 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 | } |