feat(catalogue): a curated file of providers and models, refreshed daily

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.
This commit is contained in:
2026-08-20 22:15:35 +01:00
parent 82ca696ccf
commit 0de38f0b13
11 changed files with 641 additions and 12 deletions
+14 -1
View File
@@ -3,6 +3,7 @@ import { createRouter } from '../create-router';
import { encrypt } from '../crypto';
import { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { refreshCatalogue } from '../catalogue/refresh';
import { openaiError, UUID_PATTERN } from '../errors';
export const providersRouter = createRouter();
@@ -20,7 +21,19 @@ const notFound = (id: string) => openaiError(`No provider with id "${id}".`, 'no
*/
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() }));
providersRouter.get('/adapters', (ctx) => ctx.json({ data: adapterCatalogue() }));
providersRouter.get('/adapters', async (ctx) => ctx.json({ data: await adapterCatalogue() }));
/**
* Refresh the catalogue now, rather than waiting for the daily pass.
*
* Here rather than on a timer alone because the first thing anyone does after adding a key is expect
* the model list to reflect it — the daily refresh has no key to work with until one exists, so the
* pass that matters most is the one immediately after.
*/
providersRouter.post('/catalogue/refresh', async (ctx) => {
const catalogue = await refreshCatalogue();
return ctx.json({ data: { updated_at: catalogue.updatedAt, providers: catalogue.providers.length } });
});
providersRouter.post('/', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;