Files
atelier/sidecar/api/providers.ts
T
pastilhas 0de38f0b13 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.
2026-08-20 22:15:35 +01:00

125 lines
5.6 KiB
TypeScript

import { createProvider, deleteProvider, getProvider, getProviderByName, updateProvider } from '../db';
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();
const notFound = (id: string) => openaiError(`No provider with id "${id}".`, 'not_found');
/**
* Providers are rows; adapters are code.
*
* `GET /api/providers` lists what is configured — several may share an adapter, which is the point.
* `GET /api/providers/adapters` lists what a new one can be built on.
*
* No response includes a key, in any form. Not masked either: a mask still leaks length and shape,
* and nothing needs it. The only questions are whether one is set and where it came from.
*/
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() }));
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;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const adapterName = typeof body.adapter === 'string' ? body.adapter : '';
const adapter = adapterByName(adapterName);
if (!adapter) {
return ctx.json(openaiError(`Unknown adapter "${adapterName}".`, 'invalid_request_error', 'adapter'), 400);
}
const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : adapter.defaultModel;
// Named after what it is, unless the caller had a better idea. Two providers on one adapter need
// distinguishable names, and "OpenAI" alone stops being one the moment there are two.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : `${adapter.label} · ${model}`;
if (await getProviderByName(name)) {
return ctx.json(openaiError(`A provider named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
}
const row = await createProvider({
name,
adapter: adapter.name,
model,
apiKeyEncrypted: typeof body.apiKey === 'string' && body.apiKey.trim() ? encrypt(body.apiKey.trim()) : null,
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
isDefault: Boolean(body.isDefault),
});
return ctx.json({ data: await providerStatuses(), created: row.id }, 201);
});
providersRouter.patch('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const values: Record<string, unknown> = {};
// Undefined means "leave alone" and null means "clear" — the UI sends `enabled` on its own when
// toggling, and a naive assignment would blank the credential every time someone flipped a switch.
if (body.apiKey !== undefined) {
if (body.apiKey === null || body.apiKey === '') values.apiKeyEncrypted = null;
else if (typeof body.apiKey === 'string') values.apiKeyEncrypted = encrypt(body.apiKey.trim());
else return ctx.json(openaiError("'apiKey' must be a string or null.", 'invalid_request_error', 'apiKey'), 400);
}
if (body.model !== undefined) {
if (typeof body.model !== 'string' || !body.model.trim()) {
return ctx.json(openaiError("'model' must be a non-empty string.", 'invalid_request_error', 'model'), 400);
}
values.model = body.model.trim();
}
if (body.name !== undefined) {
if (typeof body.name !== 'string' || !body.name.trim()) {
return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
}
const clash = await getProviderByName(body.name.trim());
if (clash && clash.id !== id) {
return ctx.json(
openaiError(`A provider named "${body.name.trim()}" already exists.`, 'invalid_request_error', 'name'),
409,
);
}
values.name = body.name.trim();
}
if (body.enabled !== undefined) values.enabled = Boolean(body.enabled);
if (body.isDefault !== undefined) values.isDefault = Boolean(body.isDefault);
if (Object.keys(values).length === 0)
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
await updateProvider(id, values);
return ctx.json({ data: await providerStatuses() });
});
/** Removes the configuration. Images it made keep their adapter and model as text and are unaffected. */
providersRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
await deleteProvider(id);
return ctx.json({ data: await providerStatuses() });
});