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.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|