221 lines · 9.0 KB · TypeScript
RawDownload
/** * ModelManifest — decides which provider models are current and which belong * in the model picker's legacy section. * * The classification data (current slugs per driver kind) lives in * `model-manifest.json` next to this file. The bundled copy ships with every * release. At runtime the service refreshes it from the same file on `main` * via raw.githubusercontent.com, so a new model can leave the legacy section * with a commit to `main` instead of a release. Preference order is remote, * then the on-disk copy of the last successful fetch, then the bundle. A * failed fetch never fails a provider check. * * Drivers apply the manifest to snapshot drafts with `applyModelManifest` * before publishing, so every path that produces models (pending, probe, * error fallbacks) is classified the same way. */import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts";import * as ServerSettings from "../serverSettings.ts";import bundledManifestJson from "./model-manifest.json" with { type: "json" };import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL =  "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; /** How long a fetched manifest stays fresh before the next probe re-fetches. */const MANIFEST_TTL_MS = 60 * 60 * 1000; /** Minimum gap between fetch attempts after a failure, so an offline server * does not pay a network timeout on every provider check. */const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; /** * `version` gates breaking schema changes: a build only accepts remote * manifests whose version it understands, and keeps its bundled copy * otherwise. `currentModels` is keyed by driver kind; kinds absent from the * map have no legacy concept and their models are left unflagged. */const ModelManifestSchema = Schema.Struct({  version: Schema.Literal(1),  currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),});export type ModelManifestData = typeof ModelManifestSchema.Type; const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData =  Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** On-disk shape of the last successfully fetched manifest. */const ManifestCacheFile = Schema.Struct({  fetchedAtMs: Schema.Number,  manifest: ModelManifestSchema,});const decodeManifestCache = Schema.decodeUnknownEffect(  Schema.fromJsonString(    ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,  ),);const encodeManifestCache = Schema.encodeEffect(  Schema.fromJsonString(    ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,  ),); /** True when the manifest classifies `slug` as legacy for `driverKind`. */export function isLegacyModel(  manifest: ModelManifestData,  driverKind: ProviderDriverKind,  slug: string,): boolean {  const currentModels = manifest.currentModels[driverKind];  if (!currentModels) return false;  return !currentModels.includes(slug);} /** * Reclassifies every built-in model on a snapshot draft against the manifest. * Custom models are user-defined and never reclassified. */export function applyModelManifest(  draft: ServerProviderDraft,  manifest: ModelManifestData,  driverKind: ProviderDriverKind,): ServerProviderDraft {  return { ...draft, models: classifyModels(draft.models, manifest, driverKind) };} /** Model-level half of `applyModelManifest`, exported for focused tests. */export function classifyModels(  models: ReadonlyArray<ServerProviderModel>,  manifest: ModelManifestData,  driverKind: ProviderDriverKind,): ReadonlyArray<ServerProviderModel> {  return models.map((model) => {    if (model.isCustom) return model;    if (isLegacyModel(manifest, driverKind, model.slug)) {      return model.isLegacy ? model : { ...model, isLegacy: true };    }    if (!model.isLegacy) return model;    const { isLegacy: _isLegacy, ...rest } = model;    return rest;  });} export class ModelManifest extends Context.Service<  ModelManifest,  {    /** Manifest already in memory (disk cache or bundle); never fetches.     * Snapshot classification reads this, so it never waits on the network. */    readonly current: Effect.Effect<ModelManifestData>;    /** Manifest after a TTL-gated remote refresh; never fails. */    readonly refresh: Effect.Effect<ModelManifestData>;    /** Forks `refresh` into the service's own scope. Drivers call this from     * provider checks: the fetch is process-shared state, so it must survive     * the teardown of whichever instance happened to trigger it. */    readonly refreshInBackground: Effect.Effect<void>;  }>()("t3/provider/ModelManifest") {} /** Constant service for tests and callers that only need the bundled data. */export const BundledOnlyModelManifest: ModelManifest["Service"] = {  current: Effect.succeed(BUNDLED_MODEL_MANIFEST),  refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),  refreshInBackground: Effect.void,}; export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); export const make = Effect.gen(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const config = yield* ServerConfig;  const settingsService = yield* ServerSettings.ServerSettingsService;  const httpClient = yield* HttpClient.HttpClient;  const serviceScope = yield* Effect.scope;   const cachePath = path.join(config.stateDir, "model-manifest.json");  let manifest = BUNDLED_MODEL_MANIFEST;  let fetchedAtMs: number | null = null;  let lastAttemptMs: number | null = null;  const refreshSemaphore = yield* Semaphore.make(1);   // `Effect.cached` makes concurrent first readers await the same disk load  // rather than racing a "loaded" flag. Only `refreshed` takes the fetch  // semaphore; `current` must never wait behind an in-flight network refresh.  const ensureDiskCacheLoaded = yield* Effect.cached(    Effect.gen(function* () {      const fromDisk = yield* fileSystem.readFileString(cachePath).pipe(        Effect.flatMap((raw) => decodeManifestCache(raw)),        Effect.catchCause(() => Effect.succeed(null)),      );      if (fromDisk === null) return;      // The disk copy is the last-seen remote manifest, so it outranks the      // bundle even when stale: it is refreshed on the next successful fetch.      manifest = fromDisk.manifest;      fetchedAtMs = fromDisk.fetchedAtMs;    }),  );   const refresh = Effect.fn("ModelManifest.refresh")(function* () {    yield* ensureDiskCacheLoaded;    const now = yield* Clock.currentTimeMillis;    // A timestamp in the future means the wall clock moved backwards (the    // disk cache crosses restarts, so monotonic time cannot cover it). Treat    // it as expired: the refetch rewrites both timestamps and self-heals.    const isWithin = (sinceMs: number | null, windowMs: number) =>      sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;    if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;    if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;     // The same switch that gates provider CLI update checks. It stops network    // fetches only: a manifest already cached on disk from an earlier fetch    // stays in effect, since the setting is about phoning home, not about    // discarding data the server already holds.    const settings = yield* settingsService.getSettings.pipe(      Effect.catchCause(() => Effect.succeed(null)),    );    if (settings !== null && !settings.enableProviderUpdateChecks) return manifest;     lastAttemptMs = now;    const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe(      Effect.flatMap(HttpClientResponse.filterStatusOk),      Effect.flatMap((response) => response.json),      Effect.flatMap((json) => decodeManifest(json)),      Effect.timeout(FETCH_TIMEOUT_MS),      Effect.catchCause(() => Effect.succeed(null)),    );    if (fetched === null) return manifest;     manifest = fetched;    fetchedAtMs = now;    yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(      Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),      Effect.catchCause(() => Effect.void),    );    return manifest;  });   const guardedRefresh = refreshSemaphore.withPermits(1)(refresh());   return ModelManifest.of({    current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),    refresh: guardedRefresh,    refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),  });}); export const layer = Layer.effect(ModelManifest, make);