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.
105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
import { join } from 'node:path';
|
|
import { DATA_DIR } from '../env';
|
|
import { SHIPPED_CATALOGUE } from './shipped';
|
|
import { CATALOGUE_REVISION, type Catalogue, type CatalogueModel, type CatalogueProvider } from './types';
|
|
|
|
/**
|
|
* Where a refreshed catalogue lives: beside the images, NOT in the plugin directory.
|
|
*
|
|
* A plugin directory is a git clone that an update replaces, so a file written there disappears on
|
|
* upgrade with nothing logging it — the same rule env.ts states for generated images, and the reason
|
|
* `$OFFICER_ROOT/data/atelier` exists at all.
|
|
*/
|
|
export const CATALOGUE_PATH = join(DATA_DIR, 'catalogue.json');
|
|
|
|
/** In-memory copy, so every add-connection dialog does not read the disk. Cleared by a refresh. */
|
|
let cached: Catalogue | null = null;
|
|
|
|
/**
|
|
* Merge a refreshed catalogue over the shipped one.
|
|
*
|
|
* Shipped is the FLOOR, never the ceiling: a refresh may correct a price, add a model or retire one,
|
|
* and it may also fail halfway. Merging rather than replacing means a refresh that returned three
|
|
* models out of five leaves the other two intact instead of deleting them, and a refresh that
|
|
* returned nothing at all changes nothing.
|
|
*
|
|
* Per model, the refreshed FIELDS win one by one rather than the refreshed OBJECT winning whole — the
|
|
* models endpoint knows ids and nothing else, so an entry built from it would otherwise wipe the
|
|
* sizes, qualities and price that were curated by hand.
|
|
*/
|
|
export function mergeCatalogue(base: Catalogue, refreshed: Catalogue | null): Catalogue {
|
|
if (!refreshed || refreshed.revision !== base.revision) return base;
|
|
|
|
const providers: CatalogueProvider[] = base.providers.map((provider) => {
|
|
const incoming = refreshed.providers.find((entry) => entry.name === provider.name);
|
|
if (!incoming) return provider;
|
|
|
|
const models: CatalogueModel[] = provider.models.map((model) => {
|
|
const update = incoming.models.find((entry) => entry.id === model.id);
|
|
return update ? { ...model, ...definedOnly(update) } : model;
|
|
});
|
|
|
|
// Models the refresh found that we have never shipped: kept, with whatever it established and
|
|
// nothing invented. They are usable — a request names a model by string — and honest about cost.
|
|
for (const model of incoming.models) {
|
|
if (!models.some((entry) => entry.id === model.id)) models.push(model);
|
|
}
|
|
|
|
return { ...provider, ...definedOnly(incoming), models };
|
|
});
|
|
|
|
// A provider the refresh knows about and we do not ship. Same reasoning as a new model.
|
|
for (const provider of refreshed.providers) {
|
|
if (!providers.some((entry) => entry.name === provider.name)) providers.push(provider);
|
|
}
|
|
|
|
return { revision: base.revision, updatedAt: refreshed.updatedAt, providers };
|
|
}
|
|
|
|
/** `{...a, ...b}` with b's `undefined`s dropped, so "not established" never overwrites "curated". */
|
|
function definedOnly<T extends object>(value: T): Partial<T> {
|
|
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
}
|
|
|
|
async function readRefreshed(): Promise<Catalogue | null> {
|
|
try {
|
|
const file = Bun.file(CATALOGUE_PATH);
|
|
if (!(await file.exists())) return null;
|
|
const parsed = (await file.json()) as Catalogue;
|
|
// A file written by a plugin with a different shape is ignored rather than half-read. It is not
|
|
// corruption — it is last month's format, and the shipped catalogue is a better answer than a
|
|
// partially-understood one.
|
|
if (parsed?.revision !== CATALOGUE_REVISION || !Array.isArray(parsed.providers)) {
|
|
console.warn(`[atelier:catalogue] ignoring ${CATALOGUE_PATH}: revision ${parsed?.revision} is not ${CATALOGUE_REVISION}`);
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch (err) {
|
|
console.error('[atelier:catalogue] could not read the refreshed catalogue:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** The catalogue as everything else should see it: shipped, with any successful refresh merged over. */
|
|
export async function getCatalogue(): Promise<Catalogue> {
|
|
if (cached) return cached;
|
|
cached = mergeCatalogue(SHIPPED_CATALOGUE, await readRefreshed());
|
|
return cached;
|
|
}
|
|
|
|
export async function writeCatalogue(catalogue: Catalogue): Promise<void> {
|
|
await Bun.write(CATALOGUE_PATH, `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
cached = null;
|
|
}
|
|
|
|
/** When the refreshed file was last written, or null if there has never been one. */
|
|
export async function catalogueAge(): Promise<number | null> {
|
|
const file = Bun.file(CATALOGUE_PATH);
|
|
if (!(await file.exists())) return null;
|
|
return Date.now() - file.lastModified;
|
|
}
|
|
|
|
export function invalidateCatalogue(): void {
|
|
cached = null;
|
|
}
|