Open History to see the last commit
1import {
2 ModelCapabilities,
3 TrimmedNonEmptyString,
4 type ProviderDriverKind,
5} from "@t3tools/contracts";
6import * as Clock from "effect/Clock";
7import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Schema from "effect/Schema";
14import * as Semaphore from "effect/Semaphore";
15import { HttpClient, HttpClientResponse } from "effect/http";
16
17import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
18import { ServerConfig } from "../config.ts";
19import * as ServerSettings from "../serverSettings.ts";
20import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
21import bundledManifestJson from "./model-manifest.json" with { type: "json" };
22import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts";
23import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
24
25const MODEL_MANIFEST_URL =
26 "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json";
27
28const MANIFEST_TTL_MS = 60 * 60 * 1000;
29
30/** GitHub's raw CDN caches about this long, so a shorter window buys nothing. */
31const MANIFEST_ON_DEMAND_TTL_MS = 5 * 60 * 1000;
32
33/** Back off failed fetches so offline health checks do not each wait on the network. */
34const MANIFEST_RETRY_MS = 5 * 60 * 1000;
35
36const FETCH_TIMEOUT_MS = 10_000;
37const MODEL_NEW_BADGE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
38
39const ManifestModelStatus = Schema.Literals(["current", "legacy"]);
40
41const ManifestModelProfile = Schema.Struct({
42 capabilities: Schema.optional(ModelCapabilities),
43 adapter: Schema.optional(Schema.Unknown),
44});
45
46const ManifestProviderModel = Schema.Struct({
47 slug: TrimmedNonEmptyString,
48 name: TrimmedNonEmptyString,
49 shortName: Schema.optional(TrimmedNonEmptyString),
50 subProvider: Schema.optional(TrimmedNonEmptyString),
51 aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
52 status: ManifestModelStatus,
53 /** ISO date or timestamp of the model's public release, as published by its provider. */
54 releasedAt: Schema.optional(Schema.String),
55 profile: Schema.optional(TrimmedNonEmptyString),
56 adapter: Schema.optional(Schema.Unknown),
57});
58
59const ManifestProviderCatalog = Schema.Struct({
60 defaults: Schema.optional(
61 Schema.Struct({
62 chat: Schema.optional(TrimmedNonEmptyString),
63 }),
64 ),
65 profiles: Schema.Record(Schema.String, ManifestModelProfile),
66 models: Schema.Array(ManifestProviderModel),
67});
68
69const ModelManifestEnvelopeSchema = Schema.Struct({
70 version: Schema.Literal(1),
71 /** Older disk caches must not override bundled manifests; absent dates rank below any dated bundle. */
72 updatedAt: Schema.optional(Schema.String),
73 compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)),
74 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
75 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
76});
77
78const hasValidProviderCatalogReferences = (
79 manifest: typeof ModelManifestEnvelopeSchema.Type,
80): boolean =>
81 Object.values(manifest.providers ?? {}).every((catalog) => {
82 const slugs = new Set<string>();
83 const modelsAreValid = catalog.models.every((model) => {
84 if (slugs.has(model.slug)) return false;
85 slugs.add(model.slug);
86 return model.profile === undefined || catalog.profiles[model.profile] !== undefined;
87 });
88 return (
89 modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat))
90 );
91 });
92
93const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe(
94 Schema.check(
95 Schema.makeFilter(hasValidProviderCatalogReferences, {
96 expected: "unique model slugs and existing model and profile references",
97 }),
98 Schema.makeFilter(hasValidClaudeManifestAdapters, {
99 expected: "valid Claude adapter metadata",
100 }),
101 ),
102);
103export type ModelManifestData = typeof ModelManifestSchema.Type;
104
105const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
106
107export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
108 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
109
110function manifestUpdatedAtMs(manifest: ModelManifestData): number {
111 if (manifest.updatedAt === undefined) return 0;
112 const parsed = Date.parse(manifest.updatedAt);
113 return Number.isNaN(parsed) ? 0 : parsed;
114}
115
116export function resolveProviderCatalog(
117 manifest: ModelManifestData,
118 driverKind: ProviderDriverKind,
119 nowMs = DateTime.toEpochMillis(DateTime.nowUnsafe()),
120): ModelCatalog.ProviderCatalog | null {
121 const catalog = manifest.providers?.[driverKind];
122 if (!catalog) return null;
123
124 const seen = new Set<string>();
125 const models: Array<ModelCatalog.ProviderCatalogModel> = [];
126 for (const entry of catalog.models) {
127 if (seen.has(entry.slug)) return null;
128 seen.add(entry.slug);
129
130 const profile = entry.profile ? catalog.profiles[entry.profile] : undefined;
131 if (entry.profile && !profile) return null;
132
133 const releasedAtMs = entry.releasedAt === undefined ? NaN : Date.parse(entry.releasedAt);
134 const showNewBadge =
135 entry.status === "current" &&
136 nowMs >= releasedAtMs &&
137 nowMs - releasedAtMs < MODEL_NEW_BADGE_DURATION_MS;
138
139 models.push({
140 slug: entry.slug,
141 name: entry.name,
142 ...(entry.shortName ? { shortName: entry.shortName } : {}),
143 ...(entry.subProvider ? { subProvider: entry.subProvider } : {}),
144 ...(entry.aliases ? { aliases: entry.aliases } : {}),
145 ...(showNewBadge ? { badge: "new" as const } : {}),
146 status: entry.status,
147 capabilities: profile?.capabilities ?? null,
148 adapter: entry.adapter,
149 profileAdapter: profile?.adapter,
150 });
151 }
152
153 if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null;
154
155 return { models, defaultChatModel: catalog.defaults?.chat };
156}
157
158const ManifestCacheFile = Schema.Struct({
159 fetchedAtMs: Schema.Number,
160 manifest: ModelManifestSchema,
161});
162const decodeManifestCache = Schema.decodeUnknownEffect(
163 Schema.fromJsonString(
164 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
165 ),
166);
167export const encodeManifestCache = Schema.encodeEffect(
168 Schema.fromJsonString(
169 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
170 ),
171);
172
173export class ModelManifest extends Context.Service<
174 ModelManifest,
175 {
176 /** Snapshot classification reads this, so it never waits on the network. */
177 readonly current: Effect.Effect<ModelManifestData>;
178 readonly refresh: Effect.Effect<ModelManifestData>;
179 readonly forceRefresh: Effect.Effect<ModelManifestData>;
180 /** For a user looking at the model list: tolerates far less staleness than `refresh`. */
181 readonly refreshOnDemand: Effect.Effect<ModelManifestData>;
182 /** Process-shared fetches must survive the triggering instance's teardown. */
183 readonly refreshInBackground: Effect.Effect<void>;
184 }
185>()("t3/provider/ModelManifest") {}
186
187const BundledOnlyModelManifest: ModelManifest["Service"] = {
188 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),
189 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
190 forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
191 refreshOnDemand: Effect.succeed(BUNDLED_MODEL_MANIFEST),
192 refreshInBackground: Effect.void,
193};
194
195const layerBundledOnly = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
196
197export const make = Effect.gen(function* () {
198 const fileSystem = yield* FileSystem.FileSystem;
199 const path = yield* Path.Path;
200 const config = yield* ServerConfig;
201 const settingsService = yield* ServerSettings.ServerSettingsService;
202 const httpClient = yield* HttpClient.HttpClient;
203 const serviceScope = yield* Effect.scope;
204
205 const cachePath = path.join(config.stateDir, "model-manifest.json");
206 let manifest = BUNDLED_MODEL_MANIFEST;
207 let fetchedAtMs: number | null = null;
208 let lastAttemptMs: number | null = null;
209 const refreshSemaphore = yield* Semaphore.make(1);
210
211 // Only refresh takes the fetch semaphore; current must not wait on network I/O.
212 const ensureDiskCacheLoaded = yield* Effect.cached(
213 Effect.gen(function* () {
214 const fromDisk = yield* fileSystem.readFileString(cachePath).pipe(
215 Effect.flatMap((raw) => decodeManifestCache(raw)),
216 Effect.catchCause(() => Effect.succeed(null)),
217 );
218 if (fromDisk === null) return;
219 if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {
220 return;
221 }
222 manifest = fromDisk.manifest;
223 fetchedAtMs = fromDisk.fetchedAtMs;
224 }),
225 );
226
227 const refresh = Effect.fn("ModelManifest.refresh")(function* (
228 force = false,
229 ttlMs = MANIFEST_TTL_MS,
230 ) {
231 yield* ensureDiskCacheLoaded;
232 const now = yield* Clock.currentTimeMillis;
233 // Expire future timestamps after clock rollback; disk caches cross restarts, ruling out monotonic time.
234 const isWithin = (sinceMs: number | null, windowMs: number) =>
235 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;
236 if (!force && isWithin(fetchedAtMs, ttlMs)) return manifest;
237 if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
238
239 const settings = yield* settingsService.getSettings.pipe(
240 Effect.catchCause(() => Effect.succeed(null)),
241 );
242 if (settings !== null && !settings.enableProviderUpdateChecks) return manifest;
243
244 lastAttemptMs = now;
245 const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe(
246 Effect.flatMap(HttpClientResponse.filterStatusOk),
247 Effect.flatMap((response) => response.json),
248 Effect.flatMap((json) => decodeManifest(json)),
249 Effect.timeout(FETCH_TIMEOUT_MS),
250 Effect.catchCause(() => Effect.succeed(null)),
251 );
252 // Reject stale CDN edits just like stale disk caches to preserve bundled version gates.
253 if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) {
254 return manifest;
255 }
256
257 manifest = fetched;
258 fetchedAtMs = now;
259 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
260 Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })),
261 Effect.provideService(FileSystem.FileSystem, fileSystem),
262 Effect.provideService(Path.Path, path),
263 Effect.ignoreCause,
264 );
265 return manifest;
266 });
267
268 const guardedRefresh = refreshSemaphore.withPermits(1)(refresh());
269
270 return ModelManifest.of({
271 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),
272 refresh: guardedRefresh,
273 forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)),
274 refreshOnDemand: refreshSemaphore.withPermits(1)(refresh(false, MANIFEST_ON_DEMAND_TTL_MS)),
275 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),
276 });
277});
278
279export const layer = Layer.effect(ModelManifest, make);
280
281export const layerTest = Layer.suspend(() =>
282 layerModelCatalog.pipe(Layer.provideMerge(layerBundledOnly)),
283);
284
285export const layerModelCatalog = Layer.effect(
286 ModelCatalog.ModelCatalog,
287 Effect.gen(function* () {
288 const manifest = yield* ModelManifest;
289 return ModelCatalog.ModelCatalog.of({
290 current: Effect.fn("ModelCatalog.current")(function* (driverKind: ProviderDriverKind) {
291 const data = yield* manifest.current;
292 const nowMs = yield* Clock.currentTimeMillis;
293 return resolveProviderCatalog(data, driverKind, nowMs) ?? undefined;
294 }),
295 bundled: (driverKind) =>
296 resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined,
297 refreshInBackground: manifest.refreshInBackground,
298 });
299 }),
300);