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.
125 lines
5.6 KiB
TypeScript
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() });
|
|
});
|