import { projectEnv } from '../env'; import { refreshOpenAI } from './openai-refresh'; import { SHIPPED_CATALOGUE } from './shipped'; import { catalogueAge, getCatalogue, mergeCatalogue, writeCatalogue } from './store'; import type { Catalogue } from './types'; /** * Keeping the catalogue current — the mechanism, with OpenAI as its first and only implementation. * * A provider is refreshed by one function that returns what it could establish; this file decides * when they run, merges the results, and writes the file. Adding a second provider is a second * function and a line in `REFRESHERS` — the same restraint hono.ts keeps on its mounts. */ /** Once a day. Model lists and prices move on the order of weeks; this is already generous. */ const INTERVAL_MS = Number(projectEnv('ATELIER_CATALOGUE_INTERVAL_MS') ?? 24 * 60 * 60 * 1000); const REFRESHERS = [refreshOpenAI]; /** * Run every refresher and write the result. * * Retiring happens HERE rather than in a refresher, because it is the one judgement that needs the * whole picture: a model we ship and the vendor no longer lists is deprecated, but only when the * listing actually succeeded. "The endpoint returned three models" and "the request failed after * three" are indistinguishable inside the refresher and completely different here. */ export async function refreshCatalogue(): Promise { const results = await Promise.all(REFRESHERS.map((run) => run())); let merged = SHIPPED_CATALOGUE; for (const { catalogue, listedIds } of results) { if (!catalogue) continue; merged = mergeCatalogue(merged, catalogue); if (listedIds) { const listed = new Set(listedIds); for (const provider of merged.providers) { if (!catalogue.providers.some((entry) => entry.name === provider.name)) continue; for (const model of provider.models) { // Deprecated, not deleted: the gallery labels historical rows from this list, and 88 images // name the model that made them. if (!listed.has(model.id)) model.status = 'deprecated'; } } } } const written = { ...merged, updatedAt: new Date().toISOString() }; await writeCatalogue(written); const count = written.providers.reduce((sum, provider) => sum + provider.models.length, 0); console.log(`[atelier:catalogue] refreshed — ${written.providers.length} provider(s), ${count} model(s)`); return written; } /** * Start the schedule. * * Refreshes on boot only when the file is genuinely stale, so a sidecar that restarts twenty times in * an afternoon — which this one has — does not hit OpenAI twenty times. `unref` so a pending timer * never holds the process open at shutdown. */ export async function startCatalogueRefresh(): Promise { const globalForCatalogue = globalThis as unknown as { __atelierCatalogueStarted?: boolean }; if (globalForCatalogue.__atelierCatalogueStarted) return; globalForCatalogue.__atelierCatalogueStarted = true; const age = await catalogueAge(); if (age === null || age > INTERVAL_MS) { // Not awaited: a slow or unreachable vendor must not delay the sidecar's own startup, and every // failure path here already degrades to the shipped catalogue. void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err)); } else { console.log(`[atelier:catalogue] ${Math.round(age / 3_600_000)}h old — next refresh on schedule`); } setInterval(() => { void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err)); }, INTERVAL_MS).unref(); } export { getCatalogue };