manifest, thin proxy router, and the sidecar backend
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.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
addToCollection,
|
||||
collectionImages,
|
||||
createCollection,
|
||||
deleteCollection,
|
||||
getCollection,
|
||||
getCollectionByName,
|
||||
listCollections,
|
||||
removeFromCollection,
|
||||
renameCollection,
|
||||
} from '../db';
|
||||
import { createRouter } from '../create-router';
|
||||
import { openaiError, UUID_PATTERN } from '../errors';
|
||||
import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize';
|
||||
|
||||
export const collectionsRouter = createRouter();
|
||||
|
||||
const notFound = (id: string) => openaiError(`No collection with id "${id}".`, 'not_found');
|
||||
|
||||
/** Trimmed, and required to be something. A collection called " " is a collection nobody can name again. */
|
||||
function cleanName(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
collectionsRouter.get('/', async (ctx) =>
|
||||
ctx.json({ data: (await listCollections()).map(serializeCollectionSummary) }),
|
||||
);
|
||||
|
||||
collectionsRouter.post('/', async (ctx) => {
|
||||
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
|
||||
const name = cleanName(body?.name);
|
||||
if (!name) return ctx.json(openaiError("'name' is required.", 'invalid_request_error', 'name'), 400);
|
||||
|
||||
// Answered as a conflict rather than silently returning the existing one: POST here is an explicit
|
||||
// "make a new collection", and quietly handing back someone else's is worse than refusing.
|
||||
// The generation API's ?collection= is the find-or-create path.
|
||||
if (await getCollectionByName(name)) {
|
||||
return ctx.json(openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
|
||||
}
|
||||
|
||||
const description = typeof body?.description === 'string' ? body.description : null;
|
||||
return ctx.json({ data: serializeCollection(await createCollection(name, description)) }, 201);
|
||||
});
|
||||
|
||||
collectionsRouter.get('/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
if (!UUID_PATTERN.test(id)) return ctx.json(notFound(id), 404);
|
||||
|
||||
const collection = await getCollection(id);
|
||||
if (!collection) return ctx.json(notFound(id), 404);
|
||||
|
||||
return ctx.json({
|
||||
data: { ...serializeCollection(collection), images: (await collectionImages(id)).map(serializeGeneration) },
|
||||
});
|
||||
});
|
||||
|
||||
collectionsRouter.patch('/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
|
||||
|
||||
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
|
||||
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
|
||||
|
||||
const values: { name?: string; description?: string | null } = {};
|
||||
|
||||
if (body.name !== undefined) {
|
||||
const name = cleanName(body.name);
|
||||
if (!name) return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
|
||||
|
||||
const existing = await getCollectionByName(name);
|
||||
if (existing && existing.id !== id) {
|
||||
return ctx.json(
|
||||
openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'),
|
||||
409,
|
||||
);
|
||||
}
|
||||
values.name = name;
|
||||
}
|
||||
|
||||
if (body.description !== undefined) {
|
||||
values.description = typeof body.description === 'string' ? body.description : null;
|
||||
}
|
||||
|
||||
if (Object.keys(values).length === 0) {
|
||||
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
|
||||
}
|
||||
|
||||
const updated = await renameCollection(id, values);
|
||||
return ctx.json({ data: updated ? serializeCollection(updated) : null });
|
||||
});
|
||||
|
||||
/** Removes the collection and its memberships. The images are untouched and stay in the gallery. */
|
||||
collectionsRouter.delete('/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
|
||||
|
||||
await deleteCollection(id);
|
||||
return ctx.json({ data: { id, deleted: true } });
|
||||
});
|
||||
|
||||
/** Add images. Ones already in the collection are counted as added and change nothing. */
|
||||
collectionsRouter.post('/:id/items', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
|
||||
|
||||
const body = (await ctx.req.json().catch(() => null)) as { generationIds?: unknown } | null;
|
||||
const ids = Array.isArray(body?.generationIds) ? body.generationIds : null;
|
||||
|
||||
if (!ids || ids.some((value) => typeof value !== 'string' || !UUID_PATTERN.test(value))) {
|
||||
return ctx.json(
|
||||
openaiError("'generationIds' must be an array of uuids.", 'invalid_request_error', 'generationIds'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await addToCollection(id, ids as string[]);
|
||||
return ctx.json({ data: { added, images: (await collectionImages(id)).map(serializeGeneration) } });
|
||||
} catch {
|
||||
// The foreign key is the check: an id that names no generation cannot be filed against one.
|
||||
return ctx.json(
|
||||
openaiError('One or more generationIds do not exist.', 'invalid_request_error', 'generationIds'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
collectionsRouter.delete('/:id/items/:generationId', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const generationId = ctx.req.param('generationId');
|
||||
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
|
||||
if (!UUID_PATTERN.test(generationId)) {
|
||||
return ctx.json(openaiError('Not a valid generation id.', 'invalid_request_error', 'generationId'), 400);
|
||||
}
|
||||
|
||||
await removeFromCollection(id, [generationId]);
|
||||
return ctx.json({ data: { removed: generationId, images: (await collectionImages(id)).map(serializeGeneration) } });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter } from '../create-router';
|
||||
import { IMAGES_DIR } from '../env';
|
||||
|
||||
// `GET /files/:filename` — the generated image bytes.
|
||||
//
|
||||
// Was an unauthenticated top-level `/images/*` route with a durable `PUBLIC_URL`-prefixed link on the
|
||||
// standalone service. Inside Officer it is mounted like every other route — the permission gate on the
|
||||
// platform side already authenticated the request before the proxy forwarded it here, so this route
|
||||
// itself needs no auth of its own; it only needs to refuse path traversal.
|
||||
//
|
||||
// Named `/files`, not `/images`, to avoid any ambiguity with the JSON API mounted at `/images` in
|
||||
// hono.ts — `/images/:filename` here would sit one segment away from `/images/generations/:id`.
|
||||
export const filesRouter = createRouter();
|
||||
|
||||
filesRouter.get('/:filename', async (ctx) => {
|
||||
const name = ctx.req.param('filename');
|
||||
// No traversal: the name is a filename, so anything with a separator is not one.
|
||||
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
const file = Bun.file(`${IMAGES_DIR}/${name}`);
|
||||
if (!(await file.exists())) return ctx.text('Not found', 404);
|
||||
return new Response(file, { headers: { 'cache-control': 'private, max-age=31536000, immutable' } });
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { extname, join } from 'node:path';
|
||||
import {
|
||||
addToCollection,
|
||||
createRequest,
|
||||
findOrCreateCollection,
|
||||
getCollection,
|
||||
getRequest,
|
||||
listGenerations,
|
||||
} from '../db';
|
||||
import type { NewGeneration } from '../db';
|
||||
import { createRouter } from '../create-router';
|
||||
import { resolveForRequest } from '../providers/credentials';
|
||||
import { validateImageEditRequest, validateImageRequest } from '../generations/validate';
|
||||
import { INPUTS_DIR } from '../env';
|
||||
import { pump } from '../generations/queue';
|
||||
import { imagesResponse, isSettled, jobStatus } from '../generations/response';
|
||||
import { serializeGeneration } from '../serialize';
|
||||
|
||||
export const imagesRouter = createRouter();
|
||||
|
||||
/**
|
||||
* The longest a caller may ask us to hold a connection.
|
||||
*
|
||||
* Three timeouts sit in front of the answer and the innermost has to be the one that fires, or the
|
||||
* caller gets a dropped connection instead of a status:
|
||||
*
|
||||
* nginx proxy_read_timeout 320s (data/nginx/custom/server_proxy.conf)
|
||||
* Bun idleTimeout 255s (server.ts — 255 is Bun's hard ceiling, 256 is rejected)
|
||||
* here MAX_WAIT_SECONDS 240s
|
||||
*
|
||||
* 240 rather than the 300 the documentation once promised: Bun cannot be told to wait longer than
|
||||
* 255, so a 300s wait would be cut by our own runtime at 255 with no body — the exact failure this
|
||||
* ordering exists to prevent. The ceiling is Bun's, not the proxy's, and raising it further means
|
||||
* changing runtimes rather than configuration.
|
||||
*
|
||||
* Was 55 until the proxy was raised from its 60s default, which forced every caller to poll two to
|
||||
* six times for a single 40s generation.
|
||||
*/
|
||||
const MAX_WAIT_SECONDS = 240;
|
||||
|
||||
/**
|
||||
* Resolve `?collection=` to a collection, creating one if the value is a name we have not seen.
|
||||
*
|
||||
* A query parameter and not a body field, deliberately. The body is documented as OpenAI's, byte for
|
||||
* byte, so that a client written against their API needs no changes — putting an Atelier-only key in
|
||||
* it would make that false for the sake of one string.
|
||||
*
|
||||
* A uuid must already exist: it can only have come from us, so a miss is a real mistake and answering
|
||||
* it by creating a collection named after a uuid helps nobody. A name is find-or-create, which is the
|
||||
* whole point — "put this afternoon's work in one place" should not need a setup step first.
|
||||
*/
|
||||
async function resolveCollection(value: string | undefined) {
|
||||
const name = value?.trim();
|
||||
if (!name) return { collection: undefined };
|
||||
|
||||
if (UUID_PATTERN.test(name)) {
|
||||
const collection = await getCollection(name);
|
||||
return collection ? { collection } : { error: `No collection with id "${name}".` };
|
||||
}
|
||||
|
||||
return { collection: await findOrCreateCollection(name) };
|
||||
}
|
||||
|
||||
import { openaiError, UUID_PATTERN } from '../errors';
|
||||
|
||||
/**
|
||||
* `POST /api/images/generations` — OpenAI's request body, verbatim.
|
||||
*
|
||||
* Answers 202 with a job rather than the image. OpenAI's own endpoint blocks for the length of the
|
||||
* generation; holding that open for `n: 10` would tie a connection to work that takes minutes, and the
|
||||
* callers here are agents that can poll. The finished job returns their exact response shape.
|
||||
*/
|
||||
imagesRouter.post('/generations', async (ctx) => {
|
||||
const body = await ctx.req.json().catch(() => null);
|
||||
|
||||
const parsed = validateImageRequest(body);
|
||||
if ('error' in parsed) {
|
||||
return ctx.json(openaiError(parsed.error.message, 'invalid_request_error', parsed.error.param), 400);
|
||||
}
|
||||
|
||||
const request = parsed.request;
|
||||
|
||||
// `?provider=` names one explicitly; otherwise the model decides, falling back to the default.
|
||||
const resolvedProvider = await resolveForRequest({ providerRef: ctx.req.query('provider'), model: request.model });
|
||||
if ('error' in resolvedProvider) {
|
||||
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
|
||||
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
|
||||
}
|
||||
const { provider, adapter } = resolvedProvider;
|
||||
|
||||
const resolved = await resolveCollection(ctx.req.query('collection'));
|
||||
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
|
||||
|
||||
// The request's model wins over the provider's, so a provider can serve a model nobody configured.
|
||||
const model = request.model ?? provider.model;
|
||||
const requestId = randomUUID();
|
||||
const count = request.n ?? 1;
|
||||
|
||||
// Everything except prompt/model/n — those have columns of their own, and duplicating them into
|
||||
// options would give two answers to the same question the first time one was edited.
|
||||
const { prompt, model: _model, n: _n, ...options } = request;
|
||||
|
||||
const rows: NewGeneration[] = Array.from({ length: count }, (_, index) => ({
|
||||
requestId,
|
||||
index,
|
||||
status: 'queued' as const,
|
||||
provider: adapter.name,
|
||||
providerId: provider.id,
|
||||
model,
|
||||
prompt,
|
||||
options,
|
||||
}));
|
||||
|
||||
const created = await createRequest(rows);
|
||||
|
||||
// Filed at request time rather than on completion: the collection is a statement about what was
|
||||
// asked for, and a generation that fails is still part of the afternoon it belonged to.
|
||||
if (resolved.collection)
|
||||
await addToCollection(
|
||||
resolved.collection.id,
|
||||
created.map((row) => row.id),
|
||||
);
|
||||
|
||||
pump();
|
||||
|
||||
return ctx.json(
|
||||
{
|
||||
id: requestId,
|
||||
status: 'queued',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
n: count,
|
||||
...(resolved.collection ? { collection: { id: resolved.collection.id, name: resolved.collection.name } } : {}),
|
||||
},
|
||||
202,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* `GET /api/images/generations/:id` — poll, or wait.
|
||||
*
|
||||
* `?wait=<seconds>` holds the request open until the job settles, up to 300s. Agents are the callers
|
||||
* here and a long poll spares every one of them from writing the same retry loop; without the
|
||||
* parameter it answers immediately.
|
||||
*/
|
||||
imagesRouter.get('/generations/:id', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
// Checked before the query, because Postgres rejects a malformed uuid at the cast and the failure
|
||||
// surfaces as a 500 — a client mistake reported as a server fault, outside the error envelope.
|
||||
// A caller cannot tell "never existed" from "does not exist" and should not have to.
|
||||
if (!UUID_PATTERN.test(id)) {
|
||||
return ctx.json(openaiError(`No generation request with id "${id}".`, 'not_found'), 404);
|
||||
}
|
||||
|
||||
const waitParam = Number(ctx.req.query('wait') ?? 0);
|
||||
const waitMs = Math.min(Math.max(Number.isFinite(waitParam) ? waitParam : 0, 0), MAX_WAIT_SECONDS) * 1000;
|
||||
|
||||
const deadline = Date.now() + waitMs;
|
||||
|
||||
for (;;) {
|
||||
const rows = await getRequest(id);
|
||||
if (rows.length === 0) return ctx.json(openaiError(`No generation request with id "${id}".`, 'not_found'), 404);
|
||||
|
||||
const status = jobStatus(rows);
|
||||
if (isSettled(status) || Date.now() >= deadline) {
|
||||
if (!isSettled(status)) {
|
||||
return ctx.json({ id, status, created: Math.floor(rows[0]!.createdAt.getTime() / 1000) }, 202);
|
||||
}
|
||||
const includeBase64 = (rows[0]!.options as { response_format?: string } | null)?.response_format === 'b64_json';
|
||||
return ctx.json(await imagesResponse(rows, includeBase64), status === 'failed' ? 500 : 200);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
});
|
||||
|
||||
/** Everything generated, newest first. Not OpenAI's — the history is ours. */
|
||||
imagesRouter.get('/', async (ctx) => {
|
||||
const limit = Math.min(Math.max(Number(ctx.req.query('limit') ?? 50), 1), 200);
|
||||
const rows = await listGenerations(limit);
|
||||
return ctx.json({ data: rows.map(serializeGeneration) });
|
||||
});
|
||||
|
||||
/** png / webp / jpg, per what the endpoint accepts. Kept from the upload so the provider sees a real name. */
|
||||
const SAFE_EXTENSION = /^\.(png|jpe?g|webp)$/i;
|
||||
|
||||
/**
|
||||
* `POST /api/images/edits` — OpenAI's multipart body, verbatim.
|
||||
*
|
||||
* Same job model as generations: 202 with an id, the finished job returns their response shape.
|
||||
*
|
||||
* The uploads are written to disk before the row is queued, and that ordering is the point. The work
|
||||
* happens after the request has returned, so bytes held only in the request's memory would be gone by
|
||||
* the time anything needed them — and an edit whose input has vanished cannot even be retried.
|
||||
*/
|
||||
imagesRouter.post('/edits', async (ctx) => {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await ctx.req.formData();
|
||||
} catch {
|
||||
return ctx.json(openaiError('Request must be multipart/form-data.', 'invalid_request_error'), 400);
|
||||
}
|
||||
|
||||
// Both `image` and `image[]` — the endpoint takes either, and clients send both spellings.
|
||||
const uploads = [...form.getAll('image'), ...form.getAll('image[]')].filter(
|
||||
(value): value is File => value instanceof File && value.size > 0,
|
||||
);
|
||||
|
||||
if (uploads.length === 0) {
|
||||
return ctx.json(openaiError("'image' is required.", 'invalid_request_error', 'image'), 400);
|
||||
}
|
||||
if (uploads.length > 16) {
|
||||
return ctx.json(openaiError('At most 16 images may be provided.', 'invalid_request_error', 'image'), 400);
|
||||
}
|
||||
|
||||
const maskValue = form.get('mask');
|
||||
const mask = maskValue instanceof File && maskValue.size > 0 ? maskValue : undefined;
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (typeof value === 'string') fields[key] = value;
|
||||
}
|
||||
|
||||
const parsed = validateImageEditRequest(fields);
|
||||
if ('error' in parsed) {
|
||||
return ctx.json(openaiError(parsed.error.message, 'invalid_request_error', parsed.error.param), 400);
|
||||
}
|
||||
|
||||
const request = parsed.request;
|
||||
const resolvedProvider = await resolveForRequest({ providerRef: ctx.req.query('provider'), model: request.model });
|
||||
if ('error' in resolvedProvider) {
|
||||
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
|
||||
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
|
||||
}
|
||||
const { provider, adapter } = resolvedProvider;
|
||||
|
||||
if (!adapter.edit) {
|
||||
return ctx.json(openaiError(`Adapter "${adapter.name}" cannot edit images.`, 'invalid_request_error'), 400);
|
||||
}
|
||||
|
||||
const store = async (file: File) => {
|
||||
const extension = SAFE_EXTENSION.test(extname(file.name)) ? extname(file.name).toLowerCase() : '.png';
|
||||
const name = `${randomUUID()}${extension}`;
|
||||
await Bun.write(join(INPUTS_DIR, name), await file.arrayBuffer());
|
||||
return name;
|
||||
};
|
||||
|
||||
const inputImages = await Promise.all(uploads.map(store));
|
||||
const maskImage = mask ? await store(mask) : null;
|
||||
|
||||
const resolved = await resolveCollection(ctx.req.query('collection'));
|
||||
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
|
||||
|
||||
const model = request.model ?? provider.model;
|
||||
const requestId = randomUUID();
|
||||
const count = request.n ?? 1;
|
||||
|
||||
const { prompt, model: _model, n: _n, ...options } = request;
|
||||
|
||||
const created = await createRequest(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
requestId,
|
||||
index,
|
||||
operation: 'edit' as const,
|
||||
status: 'queued' as const,
|
||||
provider: adapter.name,
|
||||
providerId: provider.id,
|
||||
model,
|
||||
prompt,
|
||||
options,
|
||||
inputImages,
|
||||
maskImage,
|
||||
})),
|
||||
);
|
||||
|
||||
if (resolved.collection)
|
||||
await addToCollection(
|
||||
resolved.collection.id,
|
||||
created.map((row) => row.id),
|
||||
);
|
||||
|
||||
pump();
|
||||
|
||||
return ctx.json(
|
||||
{
|
||||
id: requestId,
|
||||
status: 'queued',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
n: count,
|
||||
...(resolved.collection ? { collection: { id: resolved.collection.id, name: resolved.collection.name } } : {}),
|
||||
},
|
||||
202,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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() });
|
||||
});
|
||||
Reference in New Issue
Block a user