1/**
2 * ModelManifest — remote provider-model metadata with a bundled offline
3 * fallback.
4 *
5 * Provider catalogs and legacy classification live in `model-manifest.json`.
6 * The bundled copy ships with every release; at runtime the service refreshes
7 * it from the same file on `main`. Preference order is remote, then the last
8 * successful on-disk copy, then the bundle. A failed fetch never fails a
9 * provider check.
10 *
11 * Providers with authoritative discovery can use only the classification
12 * overlay. Providers with static catalogs can resolve presentation and
13 * capabilities from `providers`, then decode their own allowlisted adapter
14 * payload separately.
15 */
16import {
17 ModelCapabilities,
18 TrimmedNonEmptyString,
19 type ProviderDriverKind,
20} from "@t3tools/contracts";
21import * as Clock from "effect/Clock";
22import * as Context from "effect/Context";
23import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";
25import * as Layer from "effect/Layer";
26import * as Path from "effect/Path";
27import * as Schema from "effect/Schema";
28import * as Semaphore from "effect/Semaphore";
29import { HttpClient, HttpClientResponse } from "effect/http";
30
31import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
32import { ServerConfig } from "../config.ts";
33import * as ServerSettings from "../serverSettings.ts";
34import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
35import bundledManifestJson from "./model-manifest.json" with { type: "json" };
36import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts";
37import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
38
39const MODEL_MANIFEST_URL =
40 "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json";
41
42/** How long a fetched manifest stays fresh before the next probe re-fetches. */
43const MANIFEST_TTL_MS = 60 * 60 * 1000;
44
45/** Minimum gap between fetch attempts after a failure, so an offline server
46 * does not pay a network timeout on every provider check. */
47const MANIFEST_RETRY_MS = 5 * 60 * 1000;
48
49const FETCH_TIMEOUT_MS = 10_000;
50
51const ManifestModelStatus = Schema.Literals(["current", "legacy"]);
52
53const ManifestModelProfile = Schema.Struct({
54 capabilities: Schema.optional(ModelCapabilities),
55 adapter: Schema.optional(Schema.Unknown),
56});
57
58const ManifestProviderModel = Schema.Struct({
59 slug: TrimmedNonEmptyString,
60 name: TrimmedNonEmptyString,
61 shortName: Schema.optional(TrimmedNonEmptyString),
62 subProvider: Schema.optional(TrimmedNonEmptyString),
63 aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
64 status: ManifestModelStatus,
65 badge: Schema.optional(Schema.Literal("new")),
66 profile: Schema.optional(TrimmedNonEmptyString),
67 adapter: Schema.optional(Schema.Unknown),
68});
69
70const ManifestProviderCatalog = Schema.Struct({
71 defaults: Schema.optional(
72 Schema.Struct({
73 chat: Schema.optional(TrimmedNonEmptyString),
74 }),
75 ),
76 profiles: Schema.Record(Schema.String, ManifestModelProfile),
77 models: Schema.Array(ManifestProviderModel),
78});
79
80/**
81 * `version` gates breaking schema changes. Provider catalogs are additive so
82 * clients that only understand `currentModels` keep accepting this v1 file.
83 */
84const ModelManifestEnvelopeSchema = Schema.Struct({
85 version: Schema.Literal(1),
86 /**
87 * ISO date of the last edit. A release bundles its manifest, and a disk
88 * cache of an older edit must not outrank it. Optional so older remote
89 * files still decode; they count as older than any dated bundle.
90 */
91 updatedAt: Schema.optional(Schema.String),
92 compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)),
93 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
94 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
95});
96
97const hasValidProviderCatalogReferences = (
98 manifest: typeof ModelManifestEnvelopeSchema.Type,
99): boolean =>
100 Object.values(manifest.providers ?? {}).every((catalog) => {
101 const slugs = new Set<string>();
102 const modelsAreValid = catalog.models.every((model) => {
103 if (slugs.has(model.slug)) return false;
104 slugs.add(model.slug);
105 return model.profile === undefined || catalog.profiles[model.profile] !== undefined;
106 });
107 return (
108 modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat))
109 );
110 });
111
112const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe(
113 Schema.check(
114 Schema.makeFilter(hasValidProviderCatalogReferences, {
115 expected: "unique model slugs and existing model and profile references",
116 }),
117 Schema.makeFilter(hasValidClaudeManifestAdapters, {
118 expected: "valid Claude adapter metadata",
119 }),
120 ),
121);
122export type ModelManifestData = typeof ModelManifestSchema.Type;
123
124const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
125
126export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
127 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
128
129/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */
130function manifestUpdatedAtMs(manifest: ModelManifestData): number {
131 if (manifest.updatedAt === undefined) return 0;
132 const parsed = Date.parse(manifest.updatedAt);
133 return Number.isNaN(parsed) ? 0 : parsed;
134}
135
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 */
140export function resolveProviderCatalog(
141 manifest: ModelManifestData,
142 driverKind: ProviderDriverKind,
143): ModelCatalog.ProviderCatalog | null {
144 const catalog = manifest.providers?.[driverKind];
145 if (!catalog) return null;
146
147 const seen = new Set<string>();
148 const models: Array<ModelCatalog.ProviderCatalogModel> = [];
149 for (const entry of catalog.models) {
150 if (seen.has(entry.slug)) return null;
151 seen.add(entry.slug);
152
153 const profile = entry.profile ? catalog.profiles[entry.profile] : undefined;
154 if (entry.profile && !profile) return null;
155
156 models.push({
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,
165 adapter: entry.adapter,
166 profileAdapter: profile?.adapter,
167 });
168 }
169
170 if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null;
171
172 return { models, defaultChatModel: catalog.defaults?.chat };
173}
174
175/** On-disk shape of the last successfully fetched manifest. */
176const ManifestCacheFile = Schema.Struct({
177 fetchedAtMs: Schema.Number,
178 manifest: ModelManifestSchema,
179});
180const decodeManifestCache = Schema.decodeUnknownEffect(
181 Schema.fromJsonString(
182 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
183 ),
184);
185/** Exported for tests that seed the disk cache. */
186export const encodeManifestCache = Schema.encodeEffect(
187 Schema.fromJsonString(
188 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
189 ),
190);
191
192export class ModelManifest extends Context.Service<
193 ModelManifest,
194 {
195 /** Manifest already in memory (disk cache or bundle); never fetches.
196 * Snapshot classification reads this, so it never waits on the network. */
197 readonly current: Effect.Effect<ModelManifestData>;
198 /** Manifest after a TTL-gated remote refresh; never fails. */
199 readonly refresh: Effect.Effect<ModelManifestData>;
200 /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */
201 readonly forceRefresh: Effect.Effect<ModelManifestData>;
202 /** Forks `refresh` into the service's own scope. Drivers call this from
203 * provider checks: the fetch is process-shared state, so it must survive
204 * the teardown of whichever instance happened to trigger it. */
205 readonly refreshInBackground: Effect.Effect<void>;
206 }
207>()("t3/provider/ModelManifest") {}
208
209/** Constant service backing the bundled-data test layer. */
210const BundledOnlyModelManifest: ModelManifest["Service"] = {
211 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),
212 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
213 forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
214 refreshInBackground: Effect.void,
215};
216
217const layerBundledOnly = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
218
219export const make = Effect.gen(function* () {
220 const fileSystem = yield* FileSystem.FileSystem;
221 const path = yield* Path.Path;
222 const config = yield* ServerConfig;
223 const settingsService = yield* ServerSettings.ServerSettingsService;
224 const httpClient = yield* HttpClient.HttpClient;
225 const serviceScope = yield* Effect.scope;
226
227 const cachePath = path.join(config.stateDir, "model-manifest.json");
228 let manifest = BUNDLED_MODEL_MANIFEST;
229 let fetchedAtMs: number | null = null;
230 let lastAttemptMs: number | null = null;
231 const refreshSemaphore = yield* Semaphore.make(1);
232
233 // `Effect.cached` makes concurrent first readers await the same disk load
234 // rather than racing a "loaded" flag. Only `refreshed` takes the fetch
235 // semaphore; `current` must never wait behind an in-flight network refresh.
236 const ensureDiskCacheLoaded = yield* Effect.cached(
237 Effect.gen(function* () {
238 const fromDisk = yield* fileSystem.readFileString(cachePath).pipe(
239 Effect.flatMap((raw) => decodeManifestCache(raw)),
240 Effect.catchCause(() => Effect.succeed(null)),
241 );
242 if (fromDisk === null) return;
243 // The disk copy is the last-seen remote manifest, so it outranks the
244 // bundle even when stale, unless the bundle's own edit date is newer
245 // than the cached manifest's. Then the release carries data the cache
246 // has not seen and the cache is dropped so the next refresh replaces
247 // it. Comparing edit dates, not fetch time, keeps this independent of
248 // when the cache was written relative to the release.
249 if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {
250 return;
251 }
252 manifest = fromDisk.manifest;
253 fetchedAtMs = fromDisk.fetchedAtMs;
254 }),
255 );
256
257 const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) {
258 yield* ensureDiskCacheLoaded;
259 const now = yield* Clock.currentTimeMillis;
260 // A timestamp in the future means the wall clock moved backwards (the
261 // disk cache crosses restarts, so monotonic time cannot cover it). Treat
262 // it as expired: the refetch rewrites both timestamps and self-heals.
263 const isWithin = (sinceMs: number | null, windowMs: number) =>
264 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;
265 if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;
266 if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
267
268 // The same switch that gates provider CLI update checks. It stops network
269 // fetches only: a manifest already cached on disk from an earlier fetch
270 // stays in effect, since the setting is about phoning home, not about
271 // discarding data the server already holds.
272 const settings = yield* settingsService.getSettings.pipe(
273 Effect.catchCause(() => Effect.succeed(null)),
274 );
275 if (settings !== null && !settings.enableProviderUpdateChecks) return manifest;
276
277 lastAttemptMs = now;
278 const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe(
279 Effect.flatMap(HttpClientResponse.filterStatusOk),
280 Effect.flatMap((response) => response.json),
281 Effect.flatMap((json) => decodeManifest(json)),
282 Effect.timeout(FETCH_TIMEOUT_MS),
283 Effect.catchCause(() => Effect.succeed(null)),
284 );
285 // A CDN can still serve an earlier edit after a release. Apply the same
286 // freshness rule as the disk cache so it cannot undo bundled version gates.
287 if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) {
288 return manifest;
289 }
290
291 manifest = fetched;
292 fetchedAtMs = now;
293 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
294 Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })),
295 Effect.provideService(FileSystem.FileSystem, fileSystem),
296 Effect.provideService(Path.Path, path),
297 Effect.ignoreCause,
298 );
299 return manifest;
300 });
301
302 const guardedRefresh = refreshSemaphore.withPermits(1)(refresh());
303
304 return ModelManifest.of({
305 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),
306 refresh: guardedRefresh,
307 forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)),
308 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),
309 });
310});
311
312export 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);