The five connections in the database were never configured by anyone — a migration minted one per model from the adapter's own list, so the table held five decisions nobody made. What that list actually is, is a CATALOGUE: what you can choose from, which is not the same kind of thing as what you chose. So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh writes, and it wins where it has something to say. Data dir, not the plugin dir — a plugin directory is a clone an update replaces, which is the rule env.ts already states for images. Refresh runs in the sidecar, daily, and only on boot when the file is actually stale — this process has restarted twenty times in an afternoon and should not hit OpenAI twenty times for it. Two sources, unequal, and the code says so. `GET /v1/models` is authoritative and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/ appears the day it ships without a release. Prices come off an HTML page because there is no pricing endpoint for any plan, and that parser is written to give up rather than guess — a guess gets written to disk and then believed. When it gives up it says so in the log; the durable replacement is handing the page to a model for structured output, which Officer can already do locally. Nothing a refresh cannot establish overwrites a curated value: fields merge one by one, not objects wholesale, so an id-only listing cannot wipe the sizes and prices that were written by hand. Retiring is decided by the merge, which can tell a complete listing from a request that failed halfway — and it marks `deprecated` rather than deleting, because the gallery labels 88 historical rows from this list. Per-model sizes, qualities and transparency now travel with each entry, which is what will let the form stop offering dall-e-2 a transparent background it cannot do. The catalogue is advisory throughout: validate.ts still refuses to enforce per-model rules, exactly as before.
84 lines
3.6 KiB
TypeScript
84 lines
3.6 KiB
TypeScript
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<Catalogue> {
|
|
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<void> {
|
|
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 };
|