ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
112 lines
5.0 KiB
TypeScript
112 lines
5.0 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 { 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', (ctx) => ctx.json({ data: adapterCatalogue() }));
|
|
|
|
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() });
|
|
});
|