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 | 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 | null; if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400); const values: Record = {}; // 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() }); });