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,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
|
// /api/atelier/* — auth, then forward to officer-atelier. No routes of its own and no Atelier
|
||||||
|
// knowledge: this file must never grow app logic. See plugins/offscale/api/router.ts, the pattern
|
||||||
|
// this follows.
|
||||||
|
//
|
||||||
|
// Generation can run for tens of seconds and the queue can be mid-flight when a caller polls, so the
|
||||||
|
// proxy is given a longer idle timeout than the default — the sidecar itself already bounds a single
|
||||||
|
// poll at MAX_WAIT_SECONDS (sidecar/api/images.ts), this only has to outlast that plus network slack.
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'atelier',
|
||||||
|
prefix: `/api${mountPrefix({ appName: 'atelier', manifest })}`,
|
||||||
|
timeoutSeconds: 260,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getAtelierServerUrl = proxy.getHttpUrl;
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Atelier — image generation as a service: a provider-shaped API over OpenAI's image endpoints,
|
||||||
|
// storing what it makes so every image has a durable reference inside Officer.
|
||||||
|
//
|
||||||
|
// Extracted 2026-08-20 from a standalone server (~/officer-dev/dockers/officer-atelier) that predates
|
||||||
|
// the plugin system. See PLUGIN.md for what moved, what did not, and why — most notably that the
|
||||||
|
// `atelier` Postgres database stays separate rather than folding into officer_db (both `collections`
|
||||||
|
// and `provider_settings` had rows on the machine this was extracted from), and that generated images
|
||||||
|
// are now served through an authenticated route instead of an unauthenticated `PUBLIC_URL`.
|
||||||
|
//
|
||||||
|
// api/router.ts a thin auth-gated proxy — no Atelier knowledge, and it must never grow app logic
|
||||||
|
// sidecar/ the whole image-generation contract: providers, the queue, the database
|
||||||
|
// web/ three panels — Generate, Gallery, Collections — and a layout
|
||||||
|
//
|
||||||
|
// Providers (which adapter, which model, whose key) is NOT a panel — it moved to
|
||||||
|
// Settings → Integrations → Atelier, per PLUGIN.md's "Providers as a settings screen" decision.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Atelier',
|
||||||
|
summary: 'Image generation — prompt, gallery, collections, backed by OpenAI’s image models',
|
||||||
|
icon: 'Palette',
|
||||||
|
color: '#f59e0b',
|
||||||
|
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'atelier',
|
||||||
|
label: 'Atelier',
|
||||||
|
description: 'Image generation: prompts, gallery, collections and provider configuration',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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() });
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped state shared by every router.
|
||||||
|
*
|
||||||
|
* Empty for now. Fields land here as middleware starts setting them, so that a route reading
|
||||||
|
* `ctx.get(...)` is typed by the same declaration that the middleware writing it is checked against.
|
||||||
|
*/
|
||||||
|
export type HonoVariables = {};
|
||||||
|
|
||||||
|
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DATA_DIR, projectEnv } from './env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At-rest encryption for provider API keys.
|
||||||
|
*
|
||||||
|
* The keys are the one genuinely dangerous thing Atelier stores. Everything else in the database is a
|
||||||
|
* prompt or a timestamp; a leaked provider key is someone else's bill. So they are encrypted in the
|
||||||
|
* column rather than sitting in plaintext next to them, and the encryption key lives outside Postgres
|
||||||
|
* — a database dump on its own decrypts nothing.
|
||||||
|
*
|
||||||
|
* AES-256-GCM: authenticated, so a tampered ciphertext fails to decrypt rather than yielding garbage
|
||||||
|
* that gets sent to a provider as a credential.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY_FILE = join(DATA_DIR, 'secret.key');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated on first use rather than demanded as configuration.
|
||||||
|
*
|
||||||
|
* Requiring ATELIER_SECRET_KEY would mean the server refuses to start until someone generates 32 random
|
||||||
|
* bytes by hand, and the predictable outcome of that is a memorable passphrase padded to length. A
|
||||||
|
* generated key in a 0600 file beside the data is stronger than the one a human would have typed.
|
||||||
|
*/
|
||||||
|
function loadKey(): Buffer {
|
||||||
|
const configured = projectEnv('ATELIER_SECRET_KEY');
|
||||||
|
if (configured) {
|
||||||
|
const key = Buffer.from(configured, 'base64');
|
||||||
|
if (key.length !== 32) throw new Error('ATELIER_SECRET_KEY must be 32 bytes, base64-encoded.');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(KEY_FILE)) return Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'base64');
|
||||||
|
|
||||||
|
const key = randomBytes(32);
|
||||||
|
writeFileSync(KEY_FILE, key.toString('base64'), { mode: 0o600 });
|
||||||
|
chmodSync(KEY_FILE, 0o600);
|
||||||
|
console.log(`[crypto] generated a new encryption key at ${KEY_FILE}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Buffer | undefined;
|
||||||
|
const key = () => (cached ??= loadKey());
|
||||||
|
|
||||||
|
/** `iv:tag:ciphertext`, all base64. Self-describing, so nothing has to remember a layout. */
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||||
|
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), encrypted.toString('base64')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(payload: string): string {
|
||||||
|
const [iv, tag, data] = payload.split(':');
|
||||||
|
if (!iv || !tag || !data) throw new Error('Malformed ciphertext');
|
||||||
|
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sk-abc…wxyz` — enough to recognise which key is set, not enough to use it. */
|
||||||
|
export function maskKey(plaintext: string): string {
|
||||||
|
if (plaintext.length <= 11) return '•'.repeat(plaintext.length);
|
||||||
|
return `${plaintext.slice(0, 6)}…${plaintext.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './queries';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { generations } from '../generations/schema';
|
||||||
|
import { collectionItems, collections } from './schema';
|
||||||
|
|
||||||
|
export type Collection = typeof collections.$inferSelect;
|
||||||
|
export type CollectionWithCount = Collection & { itemCount: number; coverImagePath: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every collection, with how many images it holds and one to show for it.
|
||||||
|
*
|
||||||
|
* Counted in the query rather than by loading the memberships and calling `.length`: the list screen
|
||||||
|
* asks for this on every render, and the alternative reads every row of every collection to produce
|
||||||
|
* a number.
|
||||||
|
*/
|
||||||
|
export async function listCollections(): Promise<CollectionWithCount[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: collections.id,
|
||||||
|
name: collections.name,
|
||||||
|
description: collections.description,
|
||||||
|
createdAt: collections.createdAt,
|
||||||
|
updatedAt: collections.updatedAt,
|
||||||
|
itemCount: sql<number>`count(${collectionItems.generationId})::int`,
|
||||||
|
// The newest image that actually has a file — a failed generation makes a poor cover.
|
||||||
|
coverImagePath: sql<string | null>`
|
||||||
|
(select g.image_path from ${collectionItems} ci
|
||||||
|
join ${generations} g on g.id = ci.generation_id
|
||||||
|
where ci.collection_id = ${collections.id} and g.image_path is not null
|
||||||
|
order by g.created_at desc limit 1)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.from(collections)
|
||||||
|
.leftJoin(collectionItems, eq(collectionItems.collectionId, collections.id))
|
||||||
|
.groupBy(collections.id)
|
||||||
|
.orderBy(desc(collections.updatedAt));
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollection(id: string): Promise<Collection | undefined> {
|
||||||
|
const [row] = await db.select().from(collections).where(eq(collections.id, id)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollectionByName(name: string): Promise<Collection | undefined> {
|
||||||
|
const [row] = await db.select().from(collections).where(eq(collections.name, name)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCollection(name: string, description?: string | null): Promise<Collection> {
|
||||||
|
const [row] = await db.insert(collections).values({ name, description }).returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find by name, or make it.
|
||||||
|
*
|
||||||
|
* `onConflictDoUpdate` rather than select-then-insert: two requests naming the same new collection
|
||||||
|
* arrive together often — that is exactly what "put this afternoon's work in one place" produces —
|
||||||
|
* and the check-then-act version loses that race and fails one of them on the unique constraint.
|
||||||
|
*/
|
||||||
|
export async function findOrCreateCollection(name: string): Promise<Collection> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(collections)
|
||||||
|
.values({ name })
|
||||||
|
.onConflictDoUpdate({ target: collections.name, set: { updatedAt: new Date() } })
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCollection(id: string, values: { name?: string; description?: string | null }) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(collections)
|
||||||
|
.set({ ...values, updatedAt: new Date() })
|
||||||
|
.where(eq(collections.id, id))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The memberships go with it; the images do not. */
|
||||||
|
export async function deleteCollection(id: string): Promise<void> {
|
||||||
|
await db.delete(collections).where(eq(collections.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adding something already in the collection is a no-op, not an error. */
|
||||||
|
export async function addToCollection(collectionId: string, generationIds: string[]): Promise<number> {
|
||||||
|
if (generationIds.length === 0) return 0;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.insert(collectionItems)
|
||||||
|
.values(generationIds.map((generationId) => ({ collectionId, generationId })))
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ generationId: collectionItems.generationId });
|
||||||
|
|
||||||
|
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromCollection(collectionId: string, generationIds: string[]): Promise<void> {
|
||||||
|
if (generationIds.length === 0) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(collectionItems)
|
||||||
|
.where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.generationId, generationIds)));
|
||||||
|
|
||||||
|
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The images in a collection, newest first. */
|
||||||
|
export async function collectionImages(collectionId: string) {
|
||||||
|
return db
|
||||||
|
.select({ generation: generations, addedAt: collectionItems.addedAt })
|
||||||
|
.from(collectionItems)
|
||||||
|
.innerJoin(generations, eq(generations.id, collectionItems.generationId))
|
||||||
|
.where(eq(collectionItems.collectionId, collectionId))
|
||||||
|
.orderBy(desc(generations.createdAt))
|
||||||
|
.then((rows) => rows.map((row) => row.generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which collections a generation is in — what the "add to collection" control needs to render state. */
|
||||||
|
export async function collectionsForGenerations(generationIds: string[]): Promise<Record<string, string[]>> {
|
||||||
|
if (generationIds.length === 0) return {};
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ generationId: collectionItems.generationId, collectionId: collectionItems.collectionId })
|
||||||
|
.from(collectionItems)
|
||||||
|
.where(inArray(collectionItems.generationId, generationIds));
|
||||||
|
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
for (const row of rows) (map[row.generationId] ??= []).push(row.collectionId);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core';
|
||||||
|
import { generations } from '../generations/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named set of images — an afternoon's work, an icon suite, a client.
|
||||||
|
*
|
||||||
|
* `name` is unique, and that is load-bearing rather than tidy: the generation API accepts a
|
||||||
|
* collection by name and creates it when it does not exist, so without the constraint a second
|
||||||
|
* request with the same name would quietly start a second collection and split the batch in two.
|
||||||
|
*/
|
||||||
|
export const collections = pgTable('collections', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Membership, as a join table rather than a column on `generations`.
|
||||||
|
*
|
||||||
|
* An image belongs to as many collections as it earns: the icon that came out of Tuesday's session is
|
||||||
|
* in "tuesday" and also in "shipped", and removing it from one must not touch the other. A
|
||||||
|
* collection_id column would force a choice at generation time and make re-filing a destructive edit.
|
||||||
|
*
|
||||||
|
* Both sides cascade. Deleting a collection removes its memberships and NOT its images — a collection
|
||||||
|
* is a way of looking at generations, not a container that owns them. Deleting a generation removes
|
||||||
|
* its memberships, since a row pointing at nothing is only a way to crash a join later.
|
||||||
|
*/
|
||||||
|
export const collectionItems = pgTable(
|
||||||
|
'collection_items',
|
||||||
|
{
|
||||||
|
collectionId: uuid('collection_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => collections.id, { onDelete: 'cascade' }),
|
||||||
|
generationId: uuid('generation_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => generations.id, { onDelete: 'cascade' }),
|
||||||
|
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// The composite key is what makes adding an image twice a no-op instead of a duplicate.
|
||||||
|
primaryKey({ columns: [table.collectionId, table.generationId] }),
|
||||||
|
index('collection_items_generation_idx').on(table.generationId),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
// The `atelier` Postgres database — separate from officer_db, on purpose. See PLUGIN.md.
|
||||||
|
//
|
||||||
|
// `ATELIER_POSTGRES_URL` rather than officer's own `OFFICERDEV_POSTGRES_URL`/`POSTGRES_URL`: this
|
||||||
|
// plugin talks to a different database on (most likely) the same Postgres server, and the platform's
|
||||||
|
// own connection string must never be reused here by accident — the standalone service's
|
||||||
|
// `atelier_db/src/env.ts` had a whole enforcement mechanism for exactly this mistake ("a development
|
||||||
|
// process cannot reach the production database"), which no longer applies once this runs as one
|
||||||
|
// process under one PM2 entry, but the principle — don't let this plugin's queries land on the
|
||||||
|
// platform's own tables — still does.
|
||||||
|
const POSTGRES_URL = process.env.ATELIER_POSTGRES_URL;
|
||||||
|
|
||||||
|
if (!POSTGRES_URL) {
|
||||||
|
throw new Error(
|
||||||
|
'ATELIER_POSTGRES_URL environment variable is required — set it in the platform .env to the ' +
|
||||||
|
'`atelier` Postgres database this plugin should use. See plugins/atelier/PLUGIN.md.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One client per PROCESS, not one per module evaluation — see the standalone repo's original comment
|
||||||
|
* (kept because the reasoning still holds under PM2's own occasional module reloads): a fresh pool per
|
||||||
|
* reload abandons the previous one's open sockets, and a query in flight when the swap happens never
|
||||||
|
* resolves and never releases its connection.
|
||||||
|
*/
|
||||||
|
const globalForDb = globalThis as unknown as { __atelierPgClient?: ReturnType<typeof postgres> };
|
||||||
|
|
||||||
|
const client = (globalForDb.__atelierPgClient ??= postgres(POSTGRES_URL, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 30,
|
||||||
|
connect_timeout: 10,
|
||||||
|
connection: {
|
||||||
|
statement_timeout: 15_000,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until Postgres answers, or give up after `timeoutMs`.
|
||||||
|
*
|
||||||
|
* `postgres()` is lazy — nothing fails at import when the database is not up yet — so startup work
|
||||||
|
* that queries fires once, fails, and is swallowed by whatever `.catch()` it was written with. The
|
||||||
|
* restart sweep that fails interrupted rows (`failInterrupted`) is exactly that kind of work.
|
||||||
|
*/
|
||||||
|
export async function waitForDatabase(timeoutMs = 30_000): Promise<boolean> {
|
||||||
|
const started = Date.now();
|
||||||
|
let announced = false;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await client`select 1`;
|
||||||
|
if (announced) console.log('[atelier:db] Postgres is up');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (Date.now() - started >= timeoutMs) {
|
||||||
|
console.error(
|
||||||
|
`[atelier:db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!announced) {
|
||||||
|
console.log('[atelier:db] waiting for Postgres…');
|
||||||
|
announced = true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './queries';
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { and, asc, desc, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { generations } from './schema';
|
||||||
|
import type { GenerationStatus } from './schema';
|
||||||
|
|
||||||
|
export type Generation = typeof generations.$inferSelect;
|
||||||
|
export type NewGeneration = typeof generations.$inferInsert;
|
||||||
|
|
||||||
|
export async function createGeneration(values: NewGeneration): Promise<Generation> {
|
||||||
|
const [row] = await db.insert(generations).values(values).returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeneration(id: string): Promise<Generation | undefined> {
|
||||||
|
const [row] = await db.select().from(generations).where(eq(generations.id, id)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGenerations(limit = 50): Promise<Generation[]> {
|
||||||
|
return db.select().from(generations).orderBy(desc(generations.createdAt)).limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every image of one request, in the order it was asked for. This is what polling a job reads. */
|
||||||
|
export async function getRequest(requestId: string): Promise<Generation[]> {
|
||||||
|
return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRequest(rows: NewGeneration[]): Promise<Generation[]> {
|
||||||
|
return db.insert(generations).values(rows).returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The backlog, oldest first — what the queue picks up on a tick and after a restart. */
|
||||||
|
export async function claimableGenerations(limit: number): Promise<Generation[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(generations)
|
||||||
|
.where(eq(generations.status, 'queued'))
|
||||||
|
.orderBy(asc(generations.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a row to `running`, but only from `queued`.
|
||||||
|
*
|
||||||
|
* The `and(... eq(status, 'queued'))` is the whole point: two workers reaching for the same row both
|
||||||
|
* update, and only the one that got there first matches. The loser sees no returned row and moves on,
|
||||||
|
* so a generation is never paid for twice.
|
||||||
|
*/
|
||||||
|
export async function markRunning(id: string): Promise<Generation | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ status: 'running', startedAt: new Date() })
|
||||||
|
.where(and(eq(generations.id, id), eq(generations.status, 'queued')))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markSucceeded(
|
||||||
|
id: string,
|
||||||
|
values: {
|
||||||
|
imagePath: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
hasAlpha?: boolean | null;
|
||||||
|
opaque?: boolean | null;
|
||||||
|
costUsd?: string | null;
|
||||||
|
usage?: Record<string, unknown> | null;
|
||||||
|
result?: Record<string, unknown> | null;
|
||||||
|
durationMs: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ ...values, status: 'succeeded', finishedAt: new Date() })
|
||||||
|
.where(eq(generations.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markFailed(id: string, error: string, durationMs?: number): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ status: 'failed', error, durationMs: durationMs ?? null, finishedAt: new Date() })
|
||||||
|
.where(eq(generations.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail whatever was mid-flight when the process died.
|
||||||
|
*
|
||||||
|
* A row left `running` has no worker behind it — the only thing that would have finished it went away
|
||||||
|
* with the last process. Without this it stays running forever, and a caller polling it waits on
|
||||||
|
* something that will never answer.
|
||||||
|
*/
|
||||||
|
export async function failInterrupted(): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.update(generations)
|
||||||
|
.set({ status: 'failed', error: 'Interrupted by a server restart', finishedAt: new Date() })
|
||||||
|
.where(inArray(generations.status, ['running' satisfies GenerationStatus]))
|
||||||
|
.returning({ id: generations.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { pgTable, uuid, text, jsonb, integer, numeric, boolean, timestamp, index, check } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of states a generation can be in. Closed, so it is a `check()` rather than free text —
|
||||||
|
* otherwise a status the code stopped writing sits in old rows with nothing structural saying it
|
||||||
|
* became illegal.
|
||||||
|
*/
|
||||||
|
export const GENERATION_STATUSES = ['queued', 'running', 'succeeded', 'failed'] as const;
|
||||||
|
export type GenerationStatus = (typeof GENERATION_STATUSES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which provider endpoint made the row. Closed, so a check() rather than free text.
|
||||||
|
*
|
||||||
|
* One table for both rather than a second one for edits: everything that makes a generation worth
|
||||||
|
* recording — prompt, model, cost, duration, what landed on disk — is identical, and history that has
|
||||||
|
* to be read from two places is history that gets read from one.
|
||||||
|
*/
|
||||||
|
export const GENERATION_OPERATIONS = ['generate', 'edit'] as const;
|
||||||
|
export type GenerationOperation = (typeof GENERATION_OPERATIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per image we were asked to make, whatever became of it.
|
||||||
|
*
|
||||||
|
* The row is written when the request arrives, not when it succeeds: a generation that failed, or that
|
||||||
|
* a restart interrupted, is a thing that happened and cost time, and the prompt that produced it is
|
||||||
|
* worth as much as one that worked.
|
||||||
|
*
|
||||||
|
* ── Why the image is a path and not bytes ──
|
||||||
|
*
|
||||||
|
* The files live under ATELIER_DATA_DIR, outside the repo and outside Postgres. They are served
|
||||||
|
* unauthenticated as ordinary images, so they need to be a file a static route can hand to
|
||||||
|
* `new Response(Bun.file(...))` — not a column something has to decode first.
|
||||||
|
*/
|
||||||
|
export const generations = pgTable(
|
||||||
|
'generations',
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Also the filename on disk. One id means one image, so a row can be turned into a served URL
|
||||||
|
* without a second lookup.
|
||||||
|
*/
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request these images came from — `n: 4` is four rows sharing one requestId, and the job the
|
||||||
|
* caller polls. Per-image rows rather than an array column because cost, duration and failure are
|
||||||
|
* per image: one of four can fail, and a row that averaged them would say nothing true.
|
||||||
|
*/
|
||||||
|
requestId: uuid('request_id').notNull(),
|
||||||
|
|
||||||
|
/** Position within the request, so `data[]` comes back in the order it was asked for. */
|
||||||
|
index: integer('index').notNull().default(0),
|
||||||
|
|
||||||
|
operation: text('operation').notNull().default('generate').$type<GenerationOperation>(),
|
||||||
|
|
||||||
|
status: text('status').notNull().default('queued').$type<GenerationStatus>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adapter and model, as text, denormalised on purpose.
|
||||||
|
*
|
||||||
|
* `providerId` below points at the configured provider, but that row can be renamed, repointed at
|
||||||
|
* another model, or deleted — and none of that may change what an existing image was made with.
|
||||||
|
* The row is a pointer to configuration; these two are the historical fact.
|
||||||
|
*/
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
model: text('model').notNull(),
|
||||||
|
|
||||||
|
/** The provider row this came from, when there was one. Null for rows made before providers existed. */
|
||||||
|
providerId: uuid('provider_id'),
|
||||||
|
|
||||||
|
prompt: text('prompt').notNull(),
|
||||||
|
|
||||||
|
/** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */
|
||||||
|
options: jsonb('options').$type<Record<string, unknown>>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For edits: the uploaded files this was derived from, as names under the inputs directory.
|
||||||
|
*
|
||||||
|
* Kept because an edit without its input is unreadable afterwards — the prompt alone does not say
|
||||||
|
* what was edited, and "make the hat blue" means nothing without the hat. Names rather than bytes,
|
||||||
|
* for the same reason the output is a file.
|
||||||
|
*/
|
||||||
|
inputImages: jsonb('input_images').$type<string[]>(),
|
||||||
|
maskImage: text('mask_image'),
|
||||||
|
|
||||||
|
/** Filename under the images directory. Null until the bytes exist. */
|
||||||
|
imagePath: text('image_path'),
|
||||||
|
|
||||||
|
// ── What actually landed on disk ──
|
||||||
|
//
|
||||||
|
// Measured from the written file, never echoed from the request. What was asked for and what
|
||||||
|
// arrived are exactly the two things that come apart, and the caller is an agent that cannot look
|
||||||
|
// at the image to tell the difference.
|
||||||
|
//
|
||||||
|
// `hasAlpha` and `opaque` are deliberately both here and they are not the same question.
|
||||||
|
// `hasAlpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it
|
||||||
|
// is fully opaque. A PNG with an alpha channel that the model never used is hasAlpha: true,
|
||||||
|
// opaque: true — which is a request for a transparent background that silently did not happen,
|
||||||
|
// and the single most consequential failure for an asset destined to be an app icon.
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
hasAlpha: boolean('has_alpha'),
|
||||||
|
opaque: boolean('opaque'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost in USD. `numeric` rather than a float: money that goes through binary floating point comes
|
||||||
|
* back slightly wrong, and these get summed.
|
||||||
|
*/
|
||||||
|
costUsd: numeric('cost_usd', { precision: 12, scale: 6 }),
|
||||||
|
|
||||||
|
/** Raw usage the provider reported, so cost can be recomputed if the pricing we applied was wrong. */
|
||||||
|
usage: jsonb('usage').$type<Record<string, unknown>>(),
|
||||||
|
|
||||||
|
/** What the provider said back, minus the image bytes — `revised_prompt`, echoed size and quality. */
|
||||||
|
result: jsonb('result').$type<Record<string, unknown>>(),
|
||||||
|
|
||||||
|
durationMs: integer('duration_ms'),
|
||||||
|
|
||||||
|
error: text('error'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check('generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`),
|
||||||
|
check('generations_operation_check', sql`${table.operation} in ('generate', 'edit')`),
|
||||||
|
// The queue reads "what is still to do, oldest first" on every tick and after every restart.
|
||||||
|
index('generations_status_created_idx').on(table.status, table.createdAt),
|
||||||
|
// Every poll of a job is this lookup.
|
||||||
|
index('generations_request_idx').on(table.requestId, table.index),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// One `export *` per feature, and nothing else. What a feature exports is declared in its own
|
||||||
|
// index.ts, beside the code it describes.
|
||||||
|
//
|
||||||
|
// `provider_settings` is NOT here. It is legacy — the one-row-per-adapter shape `providers` replaced —
|
||||||
|
// and nothing in this sidecar reads or writes it any more (confirmed: no reference outside the
|
||||||
|
// standalone repo's own migrate-providers.ts, which did not move). Its table and its one leftover row
|
||||||
|
// stay in the `atelier` database, untouched. See PLUGIN.md.
|
||||||
|
export * from './generations';
|
||||||
|
export * from './collections';
|
||||||
|
export * from './providers';
|
||||||
|
|
||||||
|
// The connection itself, so callers do not reach past the package for it.
|
||||||
|
export { db, waitForDatabase } from './db';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './queries';
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { and, asc, eq, ne } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { providers } from './schema';
|
||||||
|
|
||||||
|
export type Provider = typeof providers.$inferSelect;
|
||||||
|
export type NewProvider = typeof providers.$inferInsert;
|
||||||
|
|
||||||
|
export async function listProviders(): Promise<Provider[]> {
|
||||||
|
return db.select().from(providers).orderBy(asc(providers.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvider(id: string): Promise<Provider | undefined> {
|
||||||
|
const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderByName(name: string): Promise<Provider | undefined> {
|
||||||
|
const [row] = await db.select().from(providers).where(eq(providers.name, name)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which provider serves a request, in the order a caller would expect.
|
||||||
|
*
|
||||||
|
* 1. the model, if exactly one enabled provider offers it
|
||||||
|
* 2. the model, preferring the default when several do — two keys for the same model is an
|
||||||
|
* ordinary thing and the tie has to break somewhere stable
|
||||||
|
* 3. the default provider, for a model nothing is configured for
|
||||||
|
*
|
||||||
|
* Step 3 is deliberate rather than a 404. Vendors ship models faster than rows get added, and the
|
||||||
|
* request still names a model the adapter will happily pass along — refusing it would make Atelier
|
||||||
|
* the reason a brand-new model cannot be used.
|
||||||
|
*/
|
||||||
|
export async function resolveProviderForModel(model: string | undefined): Promise<Provider | undefined> {
|
||||||
|
if (model) {
|
||||||
|
const candidates = await db
|
||||||
|
.select()
|
||||||
|
.from(providers)
|
||||||
|
.where(and(eq(providers.model, model), eq(providers.enabled, true)))
|
||||||
|
.orderBy(asc(providers.createdAt));
|
||||||
|
|
||||||
|
const match = candidates.find((row) => row.isDefault) ?? candidates[0];
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defaultProvider(): Promise<Provider | undefined> {
|
||||||
|
const [byFlag] = await db
|
||||||
|
.select()
|
||||||
|
.from(providers)
|
||||||
|
.where(and(eq(providers.isDefault, true), eq(providers.enabled, true)))
|
||||||
|
.limit(1);
|
||||||
|
if (byFlag) return byFlag;
|
||||||
|
|
||||||
|
// No flag set — the oldest enabled row, so a single-provider install needs no configuration at all.
|
||||||
|
const [oldest] = await db
|
||||||
|
.select()
|
||||||
|
.from(providers)
|
||||||
|
.where(eq(providers.enabled, true))
|
||||||
|
.orderBy(asc(providers.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
return oldest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProvider(values: NewProvider): Promise<Provider> {
|
||||||
|
const [row] = await db.insert(providers).values(values).returning();
|
||||||
|
if (row?.isDefault) await clearOtherDefaults(row.id);
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(id: string, values: Partial<NewProvider>): Promise<Provider | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(providers)
|
||||||
|
.set({ ...values, updatedAt: new Date() })
|
||||||
|
.where(eq(providers.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (row?.isDefault) await clearOtherDefaults(id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(id: string): Promise<void> {
|
||||||
|
await db.delete(providers).where(eq(providers.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly one default.
|
||||||
|
*
|
||||||
|
* Enforced by clearing the others on write rather than by a partial unique index, because the write
|
||||||
|
* that sets a new default would otherwise have to clear the old one first — two statements racing to
|
||||||
|
* leave zero defaults instead of two.
|
||||||
|
*/
|
||||||
|
async function clearOtherDefaults(keepId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(providers)
|
||||||
|
.set({ isDefault: false })
|
||||||
|
.where(and(eq(providers.isDefault, true), ne(providers.id, keepId)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configured connection to a model: adapter + model + credentials.
|
||||||
|
*
|
||||||
|
* ── Why this is a row and the adapter is not ──
|
||||||
|
*
|
||||||
|
* "OpenAI" is not a thing you can generate with. "OpenAI's gpt-image-1, with this key" is. The two
|
||||||
|
* differ in how often they change: an ADAPTER is code that learns a vendor's HTTP API and is written
|
||||||
|
* once; a PROVIDER is a decision — which model, whose key, is it on — and you make several of those
|
||||||
|
* per adapter and change them at runtime.
|
||||||
|
*
|
||||||
|
* So `openai + gpt-image-1` and `openai + dall-e-3` are two rows sharing one adapter and possibly one
|
||||||
|
* key. The previous design keyed configuration on the adapter name, which made that unsayable: there
|
||||||
|
* was exactly one OpenAI, and its model was a property of it rather than the point of it.
|
||||||
|
*
|
||||||
|
* Nothing here constrains `model` against the adapter. Vendors ship models faster than a list in this
|
||||||
|
* repo is updated, and refusing a new one on release day is a worse failure than passing it through
|
||||||
|
* and letting the vendor answer.
|
||||||
|
*/
|
||||||
|
export const providers = pgTable(
|
||||||
|
'providers',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
/** What a human calls it — "OpenAI · gpt-image-1". Unique, so it can be named in a request. */
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
|
||||||
|
/** Which adapter drives it. Text, not an enum: adding an adapter must not be a schema change. */
|
||||||
|
adapter: text('adapter').notNull(),
|
||||||
|
|
||||||
|
model: text('model').notNull(),
|
||||||
|
|
||||||
|
/** AES-256-GCM via servers/crypto.ts. Never leaves the server and never appears in a response. */
|
||||||
|
apiKeyEncrypted: text('api_key_encrypted'),
|
||||||
|
|
||||||
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one to use when a request names no provider and no model, or names a model nothing serves.
|
||||||
|
*
|
||||||
|
* A column rather than "the oldest row" because it is a decision the owner makes and should be
|
||||||
|
* able to change without deleting and recreating rows. Exactly one row should carry it; the
|
||||||
|
* queries below enforce that by clearing the others when one is set.
|
||||||
|
*/
|
||||||
|
isDefault: boolean('is_default').notNull().default(false),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// Resolving a request by model is the hot path: every generation does it once.
|
||||||
|
index('providers_model_idx').on(table.model, table.enabled),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database,
|
||||||
|
// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into
|
||||||
|
// officer_db's <appname>_-prefixed convention: both `collections` and `provider_settings` had rows on
|
||||||
|
// the machine this was extracted from, and losing someone's generated images to a schema decision is
|
||||||
|
// not a trade to make unattended.
|
||||||
|
//
|
||||||
|
// `provider_settings` is deliberately not exported here — see ./index.ts.
|
||||||
|
export * from './generations/schema';
|
||||||
|
export * from './collections/schema';
|
||||||
|
export * from './providers/schema';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { OFFICER_ROOT } from '@@/data-path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where generated images and anything else durable live.
|
||||||
|
*
|
||||||
|
* `$OFFICER_ROOT/data/atelier/` — a sibling of the plugin's own directory, not inside it. A plugin
|
||||||
|
* directory is a git clone that an update replaces, so artefacts kept there would vanish on upgrade
|
||||||
|
* with nothing logging it. Same reasoning and same shape as `plugins/kiosk/artefacts.ts`, which solved
|
||||||
|
* this first.
|
||||||
|
*/
|
||||||
|
export const DATA_DIR = join(OFFICER_ROOT, 'data', 'atelier');
|
||||||
|
|
||||||
|
export const IMAGES_DIR = join(DATA_DIR, 'images');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploaded inputs for edits, kept separate from generated output.
|
||||||
|
*
|
||||||
|
* Only IMAGES_DIR is served: an edit's input is whatever the caller uploaded, and publishing it
|
||||||
|
* because it happens to sit next to the results is not a decision anyone made.
|
||||||
|
*/
|
||||||
|
export const INPUTS_DIR = join(DATA_DIR, 'inputs');
|
||||||
|
|
||||||
|
mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
|
mkdirSync(INPUTS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many images may be in flight at OpenAI at once.
|
||||||
|
*
|
||||||
|
* A cap rather than unbounded parallelism: `n: 10` is ten HTTP requests, and several callers at once
|
||||||
|
* multiply that. The work is all awaited I/O, so the process stays responsive either way — the
|
||||||
|
* provider's rate limit is the real edge, and hitting it turns a slow job into a failed one.
|
||||||
|
*/
|
||||||
|
export const MAX_CONCURRENT_GENERATIONS = Number(process.env.ATELIER_MAX_CONCURRENCY ?? 3);
|
||||||
|
|
||||||
|
/** For `crypto.ts`'s locally-generated at-rest key. Optional escape hatch, same as the standalone service. */
|
||||||
|
export const projectEnv = (key: string): string | undefined => process.env[key];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* OpenAI's error envelope, shared by every route.
|
||||||
|
*
|
||||||
|
* In one place because it is a contract, not a convenience. A client written against their API reads
|
||||||
|
* `err.error.message`, so an error that answers with a bare string — as the framework's own notFound
|
||||||
|
* and onError handlers did until 2026-08-16 — does not merely return the wrong status: it breaks the
|
||||||
|
* parse. A caller checking `type` sees nothing at all.
|
||||||
|
*/
|
||||||
|
export const openaiError = (message: string, type: string, param?: string) => ({
|
||||||
|
error: { message, type, param: param ?? null, code: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ids come back to us from our own responses; anything else never named a job that could exist. */
|
||||||
|
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export type ImageFacts = {
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
hasAlpha: boolean | null;
|
||||||
|
opaque: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNKNOWN: ImageFacts = { width: null, height: null, hasAlpha: null, opaque: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the file that was actually written.
|
||||||
|
*
|
||||||
|
* Read from disk rather than echoed from the request, because the request is what was asked for and
|
||||||
|
* this is what arrived — and those are precisely the two things that come apart. The caller is an
|
||||||
|
* agent that cannot look at the image, so anything it is not told, it cannot know.
|
||||||
|
*
|
||||||
|
* `hasAlpha` and `opaque` answer different questions and the gap between them is the point:
|
||||||
|
*
|
||||||
|
* hasAlpha: false no alpha channel at all
|
||||||
|
* hasAlpha: true, opaque: false transparency present and used
|
||||||
|
* hasAlpha: true, opaque: true an alpha channel the model never used
|
||||||
|
*
|
||||||
|
* The third is the failure worth catching. `background: "transparent"` is accepted, the job succeeds,
|
||||||
|
* and every pixel comes back fully opaque — which for an app icon is the difference between usable and
|
||||||
|
* a black square, and is invisible from both ends without this field.
|
||||||
|
*
|
||||||
|
* Never throws. A generation that produced real bytes must not be recorded as failed because the
|
||||||
|
* measurement of it did; nulls say "not known" and the image is still there.
|
||||||
|
*/
|
||||||
|
export async function inspectImage(path: string): Promise<ImageFacts> {
|
||||||
|
try {
|
||||||
|
const image = sharp(path);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
// stats() decodes the pixels — the only way to answer "is anything actually transparent", since
|
||||||
|
// the header can only say whether a channel exists. Skipped when there is no alpha channel at
|
||||||
|
// all, where the answer is already known and the decode would be waste.
|
||||||
|
const opaque = metadata.hasAlpha ? (await image.stats()).isOpaque : true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: metadata.width ?? null,
|
||||||
|
height: metadata.height ?? null,
|
||||||
|
hasAlpha: metadata.hasAlpha ?? null,
|
||||||
|
opaque,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[inspect] could not read ${path}:`, error instanceof Error ? error.message : error);
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { claimableGenerations, failInterrupted, getProvider, markFailed, markRunning, markSucceeded } from '../db';
|
||||||
|
import type { Generation } from '../db';
|
||||||
|
import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env';
|
||||||
|
import { adapterByName } from '../providers';
|
||||||
|
import { keyForProvider } from '../providers/credentials';
|
||||||
|
import { inspectImage } from './inspect';
|
||||||
|
import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worker pool.
|
||||||
|
*
|
||||||
|
* Postgres holds the queue, not memory. A row is the job, so a restart loses nothing that was waiting
|
||||||
|
* and `failInterrupted()` can tell the caller the truth about what was mid-flight. The only in-memory
|
||||||
|
* state is how many workers are awake, which is exactly the state that should not survive a restart.
|
||||||
|
*/
|
||||||
|
let running = 0;
|
||||||
|
let sweeping = false;
|
||||||
|
|
||||||
|
/** Wake the pool. Safe to call on every enqueue and after every completion; it settles on its own. */
|
||||||
|
export function pump(): void {
|
||||||
|
if (sweeping) return;
|
||||||
|
sweeping = true;
|
||||||
|
void sweep().finally(() => {
|
||||||
|
sweeping = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweep(): Promise<void> {
|
||||||
|
while (running < MAX_CONCURRENT_GENERATIONS) {
|
||||||
|
const free = MAX_CONCURRENT_GENERATIONS - running;
|
||||||
|
const candidates = await claimableGenerations(free);
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
let started = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// markRunning only transitions from 'queued', so two sweeps racing for one row cannot both win
|
||||||
|
// and the image is never paid for twice.
|
||||||
|
const claimed = await markRunning(candidate.id);
|
||||||
|
if (!claimed) continue;
|
||||||
|
|
||||||
|
started += 1;
|
||||||
|
running += 1;
|
||||||
|
void run(claimed).finally(() => {
|
||||||
|
running -= 1;
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything on offer was taken by someone else; nothing to do until the next enqueue.
|
||||||
|
if (started === 0) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(row: Generation): Promise<void> {
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = adapterByName(row.provider);
|
||||||
|
if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`);
|
||||||
|
|
||||||
|
// The provider row is looked up by id, but the adapter and model come from the ROW's own columns:
|
||||||
|
// a provider renamed or repointed after this was queued must not change what gets generated.
|
||||||
|
const provider = row.providerId ? await getProvider(row.providerId) : undefined;
|
||||||
|
const apiKey = provider
|
||||||
|
? await keyForProvider(provider)
|
||||||
|
: await keyForProvider({ adapter: row.provider, apiKeyEncrypted: null, name: row.provider } as never);
|
||||||
|
if (!apiKey) throw new Error(`No API key for adapter "${row.provider}"`);
|
||||||
|
|
||||||
|
const result =
|
||||||
|
row.operation === 'edit'
|
||||||
|
? await runEdit(row, adapter, apiKey)
|
||||||
|
: await adapter.generate(
|
||||||
|
{ ...(row.options ?? {}), prompt: row.prompt, model: row.model, n: 1 } as ImageRequest,
|
||||||
|
apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const image = result.images[0];
|
||||||
|
if (!image) throw new Error('Provider returned no image');
|
||||||
|
|
||||||
|
// The row id is the filename: one lookup gets you from a served URL back to the prompt that made it.
|
||||||
|
const filename = `${row.id}.${image.format}`;
|
||||||
|
const path = join(IMAGES_DIR, filename);
|
||||||
|
await Bun.write(path, image.bytes);
|
||||||
|
|
||||||
|
// Measured after the write, from the file itself.
|
||||||
|
const facts = await inspectImage(path);
|
||||||
|
|
||||||
|
await markSucceeded(row.id, {
|
||||||
|
imagePath: filename,
|
||||||
|
...facts,
|
||||||
|
costUsd: adapter.costUsd(row.model, result.usage),
|
||||||
|
usage: result.usage ?? null,
|
||||||
|
result: { ...result.echoed, revised_prompt: image.revisedPrompt },
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await markFailed(row.id, error instanceof Error ? error.message : String(error), Date.now() - started);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read the uploads back off disk and hand them to the provider. */
|
||||||
|
async function runEdit(row: Generation, adapter: NonNullable<ReturnType<typeof adapterByName>>, apiKey: string) {
|
||||||
|
if (!adapter.edit) throw new Error(`Adapter "${adapter.name}" cannot edit images`);
|
||||||
|
|
||||||
|
const load = async (name: string): Promise<UploadedImage> => {
|
||||||
|
const file = Bun.file(join(INPUTS_DIR, name));
|
||||||
|
if (!(await file.exists())) throw new Error(`Input image ${name} is missing`);
|
||||||
|
const extension = name.slice(name.lastIndexOf('.')).toLowerCase();
|
||||||
|
return { filename: name, bytes: new Uint8Array(await file.arrayBuffer()), type: MIME[extension] ?? 'image/png' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const images = await Promise.all((row.inputImages ?? []).map(load));
|
||||||
|
const mask = row.maskImage ? await load(row.maskImage) : undefined;
|
||||||
|
|
||||||
|
const request = { ...(row.options ?? {}), prompt: row.prompt, model: row.model } as ImageEditRequest;
|
||||||
|
return adapter.edit(request, images, mask, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once at boot, before the pool starts.
|
||||||
|
*
|
||||||
|
* Rows left `running` have no worker behind them — whatever was going to finish them died with the
|
||||||
|
* last process. Left alone they stay running forever and a caller polling one waits on an answer that
|
||||||
|
* is never coming.
|
||||||
|
*/
|
||||||
|
export async function startQueue(): Promise<void> {
|
||||||
|
// Once per process, matching the standalone repo's own guard against `bun --hot` double-starting the
|
||||||
|
// sweep loop. This process is not hot-reloaded under PM2, but the guard is free and correct either way.
|
||||||
|
const globalForQueue = globalThis as unknown as { __atelierQueueStarted?: boolean };
|
||||||
|
if (globalForQueue.__atelierQueueStarted) return;
|
||||||
|
globalForQueue.__atelierQueueStarted = true;
|
||||||
|
|
||||||
|
const interrupted = await failInterrupted();
|
||||||
|
if (interrupted > 0) console.log(`[atelier:queue] failed ${interrupted} generation(s) interrupted by a restart`);
|
||||||
|
pump();
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Generation } from '../db';
|
||||||
|
import { imagesResponse, isSettled, jobStatus } from './response';
|
||||||
|
|
||||||
|
const row = (over: Partial<Generation> = {}): Generation =>
|
||||||
|
({
|
||||||
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
requestId: '22222222-2222-2222-2222-222222222222',
|
||||||
|
index: 0,
|
||||||
|
status: 'succeeded',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-image-1',
|
||||||
|
prompt: 'a voxel duck',
|
||||||
|
options: {},
|
||||||
|
imagePath: '11111111-1111-1111-1111-111111111111.png',
|
||||||
|
costUsd: '0.010000',
|
||||||
|
usage: {
|
||||||
|
total_tokens: 100,
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 80,
|
||||||
|
input_tokens_details: { text_tokens: 20, image_tokens: 0 },
|
||||||
|
},
|
||||||
|
result: {},
|
||||||
|
durationMs: 4200,
|
||||||
|
error: null,
|
||||||
|
createdAt: new Date('2026-08-16T12:00:00Z'),
|
||||||
|
startedAt: new Date('2026-08-16T12:00:01Z'),
|
||||||
|
finishedAt: new Date('2026-08-16T12:00:05Z'),
|
||||||
|
...over,
|
||||||
|
}) as Generation;
|
||||||
|
|
||||||
|
describe('jobStatus', () => {
|
||||||
|
test('running beats queued', () =>
|
||||||
|
expect(jobStatus([row({ status: 'queued' }), row({ status: 'running' })])).toBe('running'));
|
||||||
|
test('queued while any is waiting', () => expect(jobStatus([row(), row({ status: 'queued' })])).toBe('queued'));
|
||||||
|
test('succeeded when all did', () => expect(jobStatus([row(), row()])).toBe('succeeded'));
|
||||||
|
test('failed when all did', () =>
|
||||||
|
expect(jobStatus([row({ status: 'failed' }), row({ status: 'failed' })])).toBe('failed'));
|
||||||
|
|
||||||
|
// The case n>1 exists for: three images made, one refused. Calling that "failed" discards work that
|
||||||
|
// was paid for; calling it "succeeded" hides a failure.
|
||||||
|
test('partial when some failed and some did not', () =>
|
||||||
|
expect(jobStatus([row(), row({ status: 'failed' })])).toBe('partial'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSettled', () => {
|
||||||
|
test.each(['succeeded', 'failed', 'partial'] as const)('%s is settled', (s) => expect(isSettled(s)).toBe(true));
|
||||||
|
test.each(['queued', 'running'] as const)('%s is not', (s) => expect(isSettled(s)).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imagesResponse', () => {
|
||||||
|
test('shapes an OpenAI response, with url populated', async () => {
|
||||||
|
const body = await imagesResponse([row()], false);
|
||||||
|
|
||||||
|
expect(body.created).toBe(Math.floor(new Date('2026-08-16T12:00:00Z').getTime() / 1000));
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
// The divergence that is the point of Atelier: OpenAI never returns url for GPT image models.
|
||||||
|
expect(body.data[0]!.url).toMatch(/\/files\/11111111-1111-1111-1111-111111111111\.png$/);
|
||||||
|
expect(body.data[0]).not.toHaveProperty('b64_json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits failed images from data but keeps them in the atelier block', async () => {
|
||||||
|
const body = await imagesResponse([row(), row({ status: 'failed', imagePath: null, error: 'refused' })], false);
|
||||||
|
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.atelier.images).toHaveLength(2);
|
||||||
|
expect(body.atelier.status).toBe('partial');
|
||||||
|
expect(body.atelier.images[1]!.error).toBe('refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sums usage and cost across images', async () => {
|
||||||
|
const body = await imagesResponse([row(), row({ costUsd: '0.020000' })], false);
|
||||||
|
|
||||||
|
expect(body.usage.total_tokens).toBe(200);
|
||||||
|
expect(body.usage.output_tokens).toBe(160);
|
||||||
|
expect(body.atelier.cost_usd).toBe('0.030000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports revised_prompt when the provider rewrote it', async () => {
|
||||||
|
const body = await imagesResponse([row({ result: { revised_prompt: 'a cheerful voxel duck' } })], false);
|
||||||
|
expect(body.data[0]!.revised_prompt).toBe('a cheerful voxel duck');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Generation } from '../db';
|
||||||
|
import { IMAGES_DIR } from '../env';
|
||||||
|
import { serializeGenerationImage } from '../serialize';
|
||||||
|
|
||||||
|
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status of a request, from the rows of its images.
|
||||||
|
*
|
||||||
|
* `partial` exists because `n` images can end differently: three succeed and one trips the content
|
||||||
|
* filter. Reporting that as `failed` throws away three images that exist and were paid for, and
|
||||||
|
* reporting it as `succeeded` hides a failure the caller should see.
|
||||||
|
*/
|
||||||
|
export function jobStatus(rows: Generation[]): JobStatus {
|
||||||
|
if (rows.some((row) => row.status === 'running')) return 'running';
|
||||||
|
if (rows.some((row) => row.status === 'queued')) return 'queued';
|
||||||
|
|
||||||
|
const failed = rows.filter((row) => row.status === 'failed').length;
|
||||||
|
if (failed === 0) return 'succeeded';
|
||||||
|
if (failed === rows.length) return 'failed';
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSettled = (status: JobStatus) => status === 'succeeded' || status === 'failed' || status === 'partial';
|
||||||
|
|
||||||
|
/** `/files/<name>`, relative from this plugin's own mount. See serialize.ts for why. */
|
||||||
|
export const imageUrl = (row: Generation) => `/files/${row.imagePath}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI's `ImagesResponse`, built from our rows.
|
||||||
|
*
|
||||||
|
* Deliberate divergence from OpenAI's own shape, and the reason Atelier exists: `url` is always
|
||||||
|
* populated, including for the GPT image models, where OpenAI returns base64 only. `b64_json` is
|
||||||
|
* included only when the caller asked for it, so the common response stays small.
|
||||||
|
*/
|
||||||
|
export async function imagesResponse(rows: Generation[], includeBase64: boolean) {
|
||||||
|
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.imagePath);
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
succeeded.map(async (row) => {
|
||||||
|
const item: Record<string, unknown> = { url: imageUrl(row) };
|
||||||
|
|
||||||
|
const revised = (row.result as { revised_prompt?: string } | null)?.revised_prompt;
|
||||||
|
if (revised) item.revised_prompt = revised;
|
||||||
|
|
||||||
|
if (includeBase64) {
|
||||||
|
const bytes = await Bun.file(join(IMAGES_DIR, row.imagePath!)).arrayBuffer();
|
||||||
|
item.b64_json = Buffer.from(bytes).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = rows[0];
|
||||||
|
const echoed = (first?.result ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const totalCost = rows.reduce((sum, row) => sum + Number(row.costUsd ?? 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: Math.floor((first?.createdAt?.getTime() ?? Date.now()) / 1000),
|
||||||
|
data,
|
||||||
|
...(echoed.background ? { background: echoed.background } : {}),
|
||||||
|
...(echoed.output_format ? { output_format: echoed.output_format } : {}),
|
||||||
|
...(echoed.size ? { size: echoed.size } : {}),
|
||||||
|
...(echoed.quality ? { quality: echoed.quality } : {}),
|
||||||
|
usage: mergeUsage(rows),
|
||||||
|
// Not OpenAI's, and namespaced so it cannot collide with a field they add later.
|
||||||
|
atelier: {
|
||||||
|
id: first?.requestId,
|
||||||
|
status: jobStatus(rows),
|
||||||
|
model: first?.model,
|
||||||
|
provider: first?.provider,
|
||||||
|
cost_usd: totalCost > 0 ? totalCost.toFixed(6) : null,
|
||||||
|
duration_ms: rows.reduce((max, row) => Math.max(max, row.durationMs ?? 0), 0),
|
||||||
|
// Through the shared serializer, so a field here and the same field in /images cannot drift
|
||||||
|
// into two spellings again.
|
||||||
|
images: rows.map(serializeGenerationImage),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum the per-image usage back into one object shaped like OpenAI's. */
|
||||||
|
function mergeUsage(rows: Generation[]) {
|
||||||
|
const totals = { total_tokens: 0, input_tokens: 0, output_tokens: 0, text_tokens: 0, image_tokens: 0 };
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const usage = row.usage as {
|
||||||
|
total_tokens?: number;
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
input_tokens_details?: Record<string, number>;
|
||||||
|
} | null;
|
||||||
|
if (!usage) continue;
|
||||||
|
totals.total_tokens += usage.total_tokens ?? 0;
|
||||||
|
totals.input_tokens += usage.input_tokens ?? 0;
|
||||||
|
totals.output_tokens += usage.output_tokens ?? 0;
|
||||||
|
totals.text_tokens += usage.input_tokens_details?.text_tokens ?? 0;
|
||||||
|
totals.image_tokens += usage.input_tokens_details?.image_tokens ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_tokens: totals.total_tokens,
|
||||||
|
input_tokens: totals.input_tokens,
|
||||||
|
output_tokens: totals.output_tokens,
|
||||||
|
input_tokens_details: { text_tokens: totals.text_tokens, image_tokens: totals.image_tokens },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { ImageEditRequest, ImageRequest } from '../providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a request body against OpenAI's `POST /v1/images/generations` schema.
|
||||||
|
*
|
||||||
|
* Every parameter they accept is accepted here, with their enums and their bounds. What is NOT done is
|
||||||
|
* rejecting a combination that only some models allow — `style` on `gpt-image-1`, `background` on
|
||||||
|
* `dall-e-3`. Those rules change per model and per release, and a stale copy of them here would refuse
|
||||||
|
* requests the provider would have served. The provider is the authority; its error comes back intact.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const QUALITIES = ['auto', 'high', 'medium', 'low', 'hd', 'standard'] as const;
|
||||||
|
const RESPONSE_FORMATS = ['url', 'b64_json'] as const;
|
||||||
|
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const;
|
||||||
|
const BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
|
||||||
|
const MODERATIONS = ['low', 'auto'] as const;
|
||||||
|
const STYLES = ['vivid', 'natural'] as const;
|
||||||
|
|
||||||
|
/** `auto`, one of the named sizes, or `WIDTHxHEIGHT` — which gpt-image-2 accepts arbitrarily. */
|
||||||
|
const SIZE_PATTERN = /^(auto|\d{2,5}x\d{2,5})$/;
|
||||||
|
|
||||||
|
export type ValidationError = { message: string; param?: string };
|
||||||
|
|
||||||
|
export function validateImageRequest(input: unknown): { request: ImageRequest } | { error: ValidationError } {
|
||||||
|
if (typeof input !== 'object' || input === null) {
|
||||||
|
return { error: { message: 'Request body must be a JSON object.' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = input as Record<string, unknown>;
|
||||||
|
const request: ImageRequest = { prompt: '' };
|
||||||
|
|
||||||
|
if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
|
||||||
|
return { error: { message: "'prompt' is required and must be a non-empty string.", param: 'prompt' } };
|
||||||
|
}
|
||||||
|
request.prompt = body.prompt;
|
||||||
|
|
||||||
|
if (body.model !== undefined && body.model !== null) {
|
||||||
|
if (typeof body.model !== 'string') return { error: { message: "'model' must be a string.", param: 'model' } };
|
||||||
|
request.model = body.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.n !== undefined && body.n !== null) {
|
||||||
|
if (typeof body.n !== 'number' || !Number.isInteger(body.n) || body.n < 1 || body.n > 10) {
|
||||||
|
return { error: { message: "'n' must be an integer between 1 and 10.", param: 'n' } };
|
||||||
|
}
|
||||||
|
request.n = body.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.size !== undefined && body.size !== null) {
|
||||||
|
if (typeof body.size !== 'string' || !SIZE_PATTERN.test(body.size)) {
|
||||||
|
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
|
||||||
|
}
|
||||||
|
request.size = body.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.output_compression !== undefined && body.output_compression !== null) {
|
||||||
|
const value = body.output_compression;
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) {
|
||||||
|
return {
|
||||||
|
error: { message: "'output_compression' must be an integer between 0 and 100.", param: 'output_compression' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
request.output_compression = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.partial_images !== undefined && body.partial_images !== null) {
|
||||||
|
const value = body.partial_images;
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 3) {
|
||||||
|
return { error: { message: "'partial_images' must be an integer between 0 and 3.", param: 'partial_images' } };
|
||||||
|
}
|
||||||
|
request.partial_images = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.stream !== undefined && body.stream !== null) {
|
||||||
|
if (typeof body.stream !== 'boolean') {
|
||||||
|
return { error: { message: "'stream' must be a boolean.", param: 'stream' } };
|
||||||
|
}
|
||||||
|
// Accepted and ignored. Generation here is a job the caller polls, so there is no response stream
|
||||||
|
// to put partial images on; saying so beats pretending the flag did something.
|
||||||
|
request.stream = body.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.user !== undefined && body.user !== null) {
|
||||||
|
if (typeof body.user !== 'string') return { error: { message: "'user' must be a string.", param: 'user' } };
|
||||||
|
request.user = body.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enums = [
|
||||||
|
['quality', QUALITIES],
|
||||||
|
['response_format', RESPONSE_FORMATS],
|
||||||
|
['output_format', OUTPUT_FORMATS],
|
||||||
|
['background', BACKGROUNDS],
|
||||||
|
['moderation', MODERATIONS],
|
||||||
|
['style', STYLES],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [param, allowed] of enums) {
|
||||||
|
const value = body[param];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
|
||||||
|
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
|
||||||
|
}
|
||||||
|
(request as Record<string, unknown>)[param] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edits ──
|
||||||
|
//
|
||||||
|
// A separate validator, not a flag on the one above, because OpenAI's two endpoints genuinely differ:
|
||||||
|
// edits take `input_fidelity`, have no `style` or `moderation`, and their `quality` has no `hd`.
|
||||||
|
// Sharing one validator would mean accepting each endpoint's parameters on the other.
|
||||||
|
|
||||||
|
const EDIT_QUALITIES = ['auto', 'high', 'medium', 'low', 'standard'] as const;
|
||||||
|
const INPUT_FIDELITIES = ['high', 'low'] as const;
|
||||||
|
|
||||||
|
/** Fields arrive as form strings, so numbers and booleans come in as text. */
|
||||||
|
export function validateImageEditRequest(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
): { request: ImageEditRequest } | { error: ValidationError } {
|
||||||
|
const request: ImageEditRequest = { prompt: '' };
|
||||||
|
|
||||||
|
if (!fields.prompt) {
|
||||||
|
return { error: { message: "'prompt' is required.", param: 'prompt' } };
|
||||||
|
}
|
||||||
|
request.prompt = fields.prompt;
|
||||||
|
|
||||||
|
if (fields.model !== undefined) request.model = fields.model;
|
||||||
|
if (fields.user !== undefined) request.user = fields.user;
|
||||||
|
|
||||||
|
const integers = [
|
||||||
|
['n', 1, 10],
|
||||||
|
['output_compression', 0, 100],
|
||||||
|
['partial_images', 0, 3],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [param, min, max] of integers) {
|
||||||
|
const raw = fields[param];
|
||||||
|
if (raw === undefined) continue;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value < min || value > max) {
|
||||||
|
return { error: { message: `'${param}' must be an integer between ${min} and ${max}.`, param } };
|
||||||
|
}
|
||||||
|
(request as Record<string, unknown>)[param] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.size !== undefined) {
|
||||||
|
if (!SIZE_PATTERN.test(fields.size)) {
|
||||||
|
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
|
||||||
|
}
|
||||||
|
request.size = fields.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.stream !== undefined) {
|
||||||
|
if (fields.stream !== 'true' && fields.stream !== 'false') {
|
||||||
|
return { error: { message: "'stream' must be true or false.", param: 'stream' } };
|
||||||
|
}
|
||||||
|
request.stream = fields.stream === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const enums = [
|
||||||
|
['quality', EDIT_QUALITIES],
|
||||||
|
['response_format', RESPONSE_FORMATS],
|
||||||
|
['output_format', OUTPUT_FORMATS],
|
||||||
|
['background', BACKGROUNDS],
|
||||||
|
['input_fidelity', INPUT_FIDELITIES],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [param, allowed] of enums) {
|
||||||
|
const value = fields[param];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (!(allowed as readonly string[]).includes(value)) {
|
||||||
|
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
|
||||||
|
}
|
||||||
|
(request as Record<string, unknown>)[param] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createRouter } from './create-router';
|
||||||
|
import { openaiError } from './errors';
|
||||||
|
import type { HonoVariables } from './create-router';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { imagesRouter } from './api/images';
|
||||||
|
import { providersRouter } from './api/providers';
|
||||||
|
import { collectionsRouter } from './api/collections';
|
||||||
|
import { filesRouter } from './api/files';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every router this sidecar answers, and the prefix it answers on.
|
||||||
|
*
|
||||||
|
* Bare — no `/api` prefix of its own. `createSidecarProxy` (../api/router.ts, on the platform side)
|
||||||
|
* already strips `/api/atelier` before forwarding, so what arrives here is exactly the subpath: a
|
||||||
|
* request for `/api/atelier/images/generations` arrives as `/images/generations`.
|
||||||
|
*
|
||||||
|
* `ping` and `echo` from the standalone service are not here — they were smoke-test routes with no
|
||||||
|
* caller; the sidecar's own `/_health` (see index.ts) covers liveness instead.
|
||||||
|
*/
|
||||||
|
const MOUNTS: [string, Hono<{ Variables: HonoVariables }>][] = [
|
||||||
|
['/images', imagesRouter],
|
||||||
|
['/providers', providersRouter],
|
||||||
|
['/collections', collectionsRouter],
|
||||||
|
['/files', filesRouter],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildHonoApp() {
|
||||||
|
const app = createRouter();
|
||||||
|
|
||||||
|
for (const [prefix, router] of MOUNTS) app.route(prefix, router);
|
||||||
|
|
||||||
|
app.notFound((ctx) => ctx.json(openaiError('Not found', 'not_found'), 404));
|
||||||
|
|
||||||
|
app.onError((error, ctx) => {
|
||||||
|
console.error('[atelier]', error);
|
||||||
|
return ctx.json(openaiError('Internal Server Error', 'server_error'), 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
import { buildHonoApp } from './hono';
|
||||||
|
import { waitForDatabase } from './db';
|
||||||
|
import { startQueue } from './generations/queue';
|
||||||
|
|
||||||
|
// The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation
|
||||||
|
// queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md).
|
||||||
|
//
|
||||||
|
// The platform API (../api/router.ts) is a thin auth-gated forwarder holding no provider credentials
|
||||||
|
// of its own. This process binds a loopback port and reports it over the registration socket, exactly
|
||||||
|
// like offscale's sidecar; `createSidecarProxy` on the platform side learns the port from the
|
||||||
|
// `atelier:server` event and forwards `/api/atelier/*` here with the prefix stripped.
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
function getFreePort(): number {
|
||||||
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
const p = probe.port;
|
||||||
|
probe.stop(true);
|
||||||
|
if (p == null) throw new Error('failed to acquire a free port');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = getFreePort();
|
||||||
|
const app = buildHonoApp();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// Generation runs 20-60s and a caller may long-poll for up to MAX_WAIT_SECONDS (sidecar/api/images.ts)
|
||||||
|
// — the ceiling Bun allows, one below the ceiling rejected outright.
|
||||||
|
idleTimeout: 255,
|
||||||
|
// OpenAI's edits endpoint takes up to 16 images at 50MB each.
|
||||||
|
maxRequestBodySize: 1024 * 1024 * 900,
|
||||||
|
fetch(req, srv) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
// Liveness only — matches the shape every other HTTP sidecar in this codebase uses.
|
||||||
|
if (url.pathname === '/_health') return Response.json({ ok: true });
|
||||||
|
return app.fetch(req, srv);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[atelier] listening on 127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'ping':
|
||||||
|
reply({ type: 'pong', id: cmd.id });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reply({
|
||||||
|
type: 'error',
|
||||||
|
id: (cmd as SidecarCommand).id,
|
||||||
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = createSidecarConnector({
|
||||||
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
|
name: 'atelier',
|
||||||
|
handles: ['atelier'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
// Tell the API where we're listening, so it can forward /api/atelier/* here.
|
||||||
|
connection.send({ type: 'atelier:server', port });
|
||||||
|
console.log(`[atelier] reported port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Awaited, not fired and forgotten. postgres-js is lazy, so on a cold boot the first query loses a
|
||||||
|
// race with Postgres and the restart sweep (`failInterrupted`) — the only thing that corrects rows
|
||||||
|
// left `running` by the last process — would fail once and be swallowed silently.
|
||||||
|
void (async () => {
|
||||||
|
if (await waitForDatabase()) await startQueue();
|
||||||
|
else console.error('[atelier:queue] not started: no database');
|
||||||
|
})();
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[atelier] ${signal} received, shutting down...`);
|
||||||
|
try {
|
||||||
|
server.stop(true);
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
connection.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { getSecret } from 'officerdb/secret-store';
|
||||||
|
import { getProvider, getProviderByName, listProviders, resolveProviderForModel } from '../db';
|
||||||
|
import type { Provider } from '../db';
|
||||||
|
import { decrypt } from '../crypto';
|
||||||
|
import { ADAPTERS, adapterByName } from './index';
|
||||||
|
import type { ImageAdapter } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key for a provider: its own, then the adapter's bootstrap credential.
|
||||||
|
*
|
||||||
|
* That order because the row is where the owner put a key deliberately. The bootstrap credential is
|
||||||
|
* how a key gets in before there is a UI to put one in, and how an install supplies one without a
|
||||||
|
* database write. Several providers sharing an adapter therefore also share the fallback, which is
|
||||||
|
* right: it is the same account.
|
||||||
|
*/
|
||||||
|
export async function keyForProvider(provider: Provider): Promise<string | undefined> {
|
||||||
|
if (provider.apiKeyEncrypted) {
|
||||||
|
try {
|
||||||
|
return decrypt(provider.apiKeyEncrypted);
|
||||||
|
} catch (error) {
|
||||||
|
// A key that will not decrypt is broken, not absent. Falling through to the bootstrap credential
|
||||||
|
// would hide that the stored credential is unusable.
|
||||||
|
console.error(`[atelier:providers] stored key for "${provider.name}" failed to decrypt:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bootstrapKeyFor(provider.adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adapter's bootstrap credential — the secret store first, `.env` second.
|
||||||
|
*
|
||||||
|
* `getSecret`, never `getKey`: this is a foreign credential (OpenAI's, not ours), and `getKey` mints a
|
||||||
|
* random value for an absent purpose — for a foreign credential that means silently authenticating
|
||||||
|
* with a fake. `putSecret`/`getSecret` landed 2026-08-19; `bun run secret:set atelier-openai-key`
|
||||||
|
* writes it. Falls back to `<ADAPTER>_API_KEY` in `.env` so an install that predates the secret store
|
||||||
|
* keeps working unchanged.
|
||||||
|
*
|
||||||
|
* Keyed per-adapter (`atelier-<adapter>-key`), matching `envVarFor`'s own per-adapter shape below —
|
||||||
|
* several providers on one adapter already share one fallback, and this preserves that.
|
||||||
|
*/
|
||||||
|
function bootstrapKeyFor(adapter: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const stored = getSecret(`atelier-${adapter}-key`);
|
||||||
|
if (stored) return stored;
|
||||||
|
} catch {
|
||||||
|
// Unreadable store — fall through to env, same as every other secret-store caller in this codebase.
|
||||||
|
}
|
||||||
|
return process.env[envVarFor(adapter)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `openai` -> `OPENAI_API_KEY`. Per adapter, not per provider: it is one account. */
|
||||||
|
export const envVarFor = (adapter: string) => `${adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
||||||
|
|
||||||
|
export type ResolvedProvider = { provider: Provider; adapter: ImageAdapter; apiKey: string };
|
||||||
|
|
||||||
|
/** Everything needed to run a generation, or a reason it cannot run. */
|
||||||
|
export async function resolveForRequest(options: {
|
||||||
|
providerRef?: string;
|
||||||
|
model?: string;
|
||||||
|
}): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
|
||||||
|
let provider: Provider | undefined;
|
||||||
|
|
||||||
|
if (options.providerRef) {
|
||||||
|
// Named explicitly — a uuid or the provider's name. An unknown one is the caller's mistake and
|
||||||
|
// must not silently fall back to a different provider that will bill them.
|
||||||
|
provider = /^[0-9a-f-]{36}$/i.test(options.providerRef)
|
||||||
|
? await getProvider(options.providerRef)
|
||||||
|
: await getProviderByName(options.providerRef);
|
||||||
|
|
||||||
|
if (!provider) return { error: `No provider named "${options.providerRef}".`, status: 404 };
|
||||||
|
if (!provider.enabled) return { error: `Provider "${provider.name}" is disabled.`, status: 503 };
|
||||||
|
} else {
|
||||||
|
provider = await resolveProviderForModel(options.model);
|
||||||
|
if (!provider) {
|
||||||
|
return { error: 'No provider is configured. Add one in Settings → Integrations → Atelier.', status: 503 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = adapterByName(provider.adapter);
|
||||||
|
if (!adapter)
|
||||||
|
return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 };
|
||||||
|
|
||||||
|
const apiKey = await keyForProvider(provider);
|
||||||
|
if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 };
|
||||||
|
|
||||||
|
return { provider, adapter, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the providers screen renders. Never includes a key, in any form. */
|
||||||
|
export async function providerStatuses() {
|
||||||
|
const rows = await listProviders();
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
adapter: row.adapter,
|
||||||
|
model: row.model,
|
||||||
|
enabled: row.enabled,
|
||||||
|
is_default: row.isDefault,
|
||||||
|
configured: Boolean(await keyForProvider(row)),
|
||||||
|
// Where the key came from, so the UI can say "set as a bootstrap credential" rather than showing
|
||||||
|
// an empty field next to a provider that works.
|
||||||
|
key_source: row.apiKeyEncrypted
|
||||||
|
? ('database' as const)
|
||||||
|
: (await keyForProvider(row))
|
||||||
|
? ('environment' as const)
|
||||||
|
: null,
|
||||||
|
env_var: envVarFor(row.adapter),
|
||||||
|
created_at: row.createdAt,
|
||||||
|
updated_at: row.updatedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The adapters a provider can be built on, for the "add provider" form. */
|
||||||
|
export const adapterCatalogue = () =>
|
||||||
|
ADAPTERS.map((adapter) => ({
|
||||||
|
name: adapter.name,
|
||||||
|
label: adapter.label,
|
||||||
|
models: adapter.models,
|
||||||
|
default_model: adapter.defaultModel,
|
||||||
|
env_var: envVarFor(adapter.name),
|
||||||
|
supports_edit: typeof adapter.edit === 'function',
|
||||||
|
}));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { openaiAdapter } from './openai';
|
||||||
|
import type { ImageAdapter } from './types';
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every adapter Atelier can speak — the CODE side.
|
||||||
|
*
|
||||||
|
* An adapter knows a vendor's HTTP API. It is written once and rarely changes. What model to use,
|
||||||
|
* whose key, and whether it is switched on are not its business: those are a `providers` row, made
|
||||||
|
* and edited at runtime. Several providers can share one adapter, which is the entire point — OpenAI
|
||||||
|
* with gpt-image-1 and OpenAI with dall-e-3 are two configurations of one piece of code.
|
||||||
|
*
|
||||||
|
* A list in one place, the way `hono.ts` keeps its mounts: adding an adapter is a new file plus a
|
||||||
|
* line here.
|
||||||
|
*/
|
||||||
|
export const ADAPTERS: ImageAdapter[] = [openaiAdapter];
|
||||||
|
|
||||||
|
export function adapterByName(name: string): ImageAdapter | undefined {
|
||||||
|
return ADAPTERS.find((adapter) => adapter.name === name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { ImageAdapter, ImageEditRequest, ImageRequest, ImageUsage, ProviderResult, UploadedImage } from './types';
|
||||||
|
|
||||||
|
const GENERATIONS_ENDPOINT = 'https://api.openai.com/v1/images/generations';
|
||||||
|
const EDITS_ENDPOINT = 'https://api.openai.com/v1/images/edits';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USD per million tokens, from OpenAI's published pricing.
|
||||||
|
*
|
||||||
|
* A table rather than a computed rate, and one that is allowed to be incomplete: a model missing here
|
||||||
|
* yields a null cost, which is honest. Guessing a price for a model we do not know produces a number
|
||||||
|
* that looks authoritative and sums into a total that is wrong. Every row also stores its raw `usage`,
|
||||||
|
* so any cost here can be recomputed later if these figures move.
|
||||||
|
*/
|
||||||
|
const PRICING: Record<string, { textInput: number; imageInput: number; imageOutput: number }> = {
|
||||||
|
'gpt-image-1': { textInput: 5, imageInput: 10, imageOutput: 40 },
|
||||||
|
'gpt-image-1-mini': { textInput: 2, imageInput: 2.5, imageOutput: 8 },
|
||||||
|
'gpt-image-1.5': { textInput: 5, imageInput: 8, imageOutput: 32 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only the parameters OpenAI accepts, and only those the caller actually set. */
|
||||||
|
function body(request: ImageRequest, n: number): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { prompt: request.prompt, model: request.model, n };
|
||||||
|
|
||||||
|
for (const key of [
|
||||||
|
'quality',
|
||||||
|
'size',
|
||||||
|
'response_format',
|
||||||
|
'output_format',
|
||||||
|
'output_compression',
|
||||||
|
'background',
|
||||||
|
'moderation',
|
||||||
|
'style',
|
||||||
|
'user',
|
||||||
|
] as const) {
|
||||||
|
if (request[key] !== undefined) out[key] = request[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openaiAdapter: ImageAdapter = {
|
||||||
|
name: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
models: ['gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'dall-e-3', 'dall-e-2'],
|
||||||
|
defaultModel: 'gpt-image-1',
|
||||||
|
|
||||||
|
async generate(request, apiKey, signal): Promise<ProviderResult> {
|
||||||
|
// One image per call, even when the caller asked for several. Each is its own row with its own
|
||||||
|
// cost and duration, and one failure out of four should not discard the three that worked.
|
||||||
|
const response = await fetch(GENERATIONS_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify(body(request, 1)),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return decodeImagesResponse(response, request.output_format, signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
async edit(request, images, mask, apiKey, signal): Promise<ProviderResult> {
|
||||||
|
// multipart/form-data, because that is what the endpoint takes. Built rather than translated:
|
||||||
|
// this is a bridge, and every field goes across under the name OpenAI gave it.
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('prompt', request.prompt);
|
||||||
|
form.append('n', '1');
|
||||||
|
|
||||||
|
// Repeated `image[]` — the wire form of an array of files, and what the API expects for the GPT
|
||||||
|
// image models. One image still goes as `image[]`; the endpoint accepts either.
|
||||||
|
for (const image of images) {
|
||||||
|
form.append('image[]', new Blob([image.bytes as BlobPart], { type: image.type }), image.filename);
|
||||||
|
}
|
||||||
|
if (mask) form.append('mask', new Blob([mask.bytes as BlobPart], { type: mask.type }), mask.filename);
|
||||||
|
|
||||||
|
for (const key of [
|
||||||
|
'model',
|
||||||
|
'quality',
|
||||||
|
'size',
|
||||||
|
'response_format',
|
||||||
|
'output_format',
|
||||||
|
'output_compression',
|
||||||
|
'background',
|
||||||
|
'input_fidelity',
|
||||||
|
'user',
|
||||||
|
] as const) {
|
||||||
|
const value = request[key];
|
||||||
|
if (value !== undefined) form.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No content-type header: fetch sets it, with the multipart boundary that only it knows.
|
||||||
|
const response = await fetch(EDITS_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${apiKey}` },
|
||||||
|
body: form,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return decodeImagesResponse(response, request.output_format, signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
costUsd(model, usage) {
|
||||||
|
const price = PRICING[model];
|
||||||
|
if (!price || !usage) return null;
|
||||||
|
|
||||||
|
const textTokens = usage.input_tokens_details?.text_tokens ?? usage.input_tokens ?? 0;
|
||||||
|
const imageTokens = usage.input_tokens_details?.image_tokens ?? 0;
|
||||||
|
const outputTokens = usage.output_tokens ?? 0;
|
||||||
|
|
||||||
|
const total =
|
||||||
|
(textTokens * price.textInput + imageTokens * price.imageInput + outputTokens * price.imageOutput) / 1_000_000;
|
||||||
|
|
||||||
|
return total.toFixed(6);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn OpenAI's ImagesResponse into bytes, whichever endpoint produced it.
|
||||||
|
*
|
||||||
|
* Shared by generate and edit because the response shape is identical — only the request differs.
|
||||||
|
*/
|
||||||
|
async function decodeImagesResponse(
|
||||||
|
response: Response,
|
||||||
|
requestedFormat: string | undefined,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ProviderResult> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
// Surface OpenAI's own message where there is one — "content policy violation" is worth keeping.
|
||||||
|
let detail = text.slice(0, 500);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { error?: { message?: string } };
|
||||||
|
if (parsed.error?.message) detail = parsed.error.message;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(`OpenAI ${response.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
data?: { b64_json?: string; url?: string; revised_prompt?: string }[];
|
||||||
|
usage?: ImageUsage;
|
||||||
|
background?: string;
|
||||||
|
output_format?: string;
|
||||||
|
size?: string;
|
||||||
|
quality?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = payload.output_format ?? requestedFormat ?? 'png';
|
||||||
|
|
||||||
|
const images = await Promise.all(
|
||||||
|
(payload.data ?? []).map(async (item) => {
|
||||||
|
if (item.b64_json) {
|
||||||
|
return { bytes: Buffer.from(item.b64_json, 'base64'), format, revisedPrompt: item.revised_prompt };
|
||||||
|
}
|
||||||
|
// dall-e-2 with the default response_format hands back a URL that expires in an hour. Fetch it
|
||||||
|
// now: the whole point of Atelier is that the image outlives the request.
|
||||||
|
if (item.url) {
|
||||||
|
const bytes = new Uint8Array(await (await fetch(item.url, { signal })).arrayBuffer());
|
||||||
|
return { bytes, format, revisedPrompt: item.revised_prompt };
|
||||||
|
}
|
||||||
|
throw new Error('OpenAI returned neither b64_json nor url');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
images,
|
||||||
|
usage: payload.usage,
|
||||||
|
echoed: {
|
||||||
|
background: payload.background,
|
||||||
|
output_format: payload.output_format,
|
||||||
|
size: payload.size,
|
||||||
|
quality: payload.quality,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* The request Atelier accepts, which is OpenAI's `POST /v1/images/generations` body verbatim.
|
||||||
|
*
|
||||||
|
* Kept as their vocabulary rather than a translated one of our own: a caller that already knows the
|
||||||
|
* OpenAI API knows this, and a provider added later is the one that has to do the translating —
|
||||||
|
* which is where the knowledge of that provider already lives.
|
||||||
|
*/
|
||||||
|
export type ImageRequest = {
|
||||||
|
prompt: string;
|
||||||
|
model?: string;
|
||||||
|
n?: number;
|
||||||
|
quality?: 'auto' | 'high' | 'medium' | 'low' | 'hd' | 'standard';
|
||||||
|
size?: string;
|
||||||
|
response_format?: 'url' | 'b64_json';
|
||||||
|
output_format?: 'png' | 'jpeg' | 'webp';
|
||||||
|
output_compression?: number;
|
||||||
|
background?: 'transparent' | 'opaque' | 'auto';
|
||||||
|
moderation?: 'low' | 'auto';
|
||||||
|
style?: 'vivid' | 'natural';
|
||||||
|
stream?: boolean;
|
||||||
|
partial_images?: number;
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI's `POST /v1/images/edits` body, minus the files themselves.
|
||||||
|
*
|
||||||
|
* A separate type rather than flags on ImageRequest, because the parameter sets genuinely differ:
|
||||||
|
* edits take `input_fidelity` and no `style` or `moderation`, its `quality` has no `hd`, and its
|
||||||
|
* model default is gpt-image-1.5 where generations default to dall-e-2.
|
||||||
|
*/
|
||||||
|
export type ImageEditRequest = {
|
||||||
|
prompt: string;
|
||||||
|
model?: string;
|
||||||
|
n?: number;
|
||||||
|
quality?: 'auto' | 'high' | 'medium' | 'low' | 'standard';
|
||||||
|
size?: string;
|
||||||
|
response_format?: 'url' | 'b64_json';
|
||||||
|
output_format?: 'png' | 'jpeg' | 'webp';
|
||||||
|
output_compression?: number;
|
||||||
|
background?: 'transparent' | 'opaque' | 'auto';
|
||||||
|
input_fidelity?: 'high' | 'low';
|
||||||
|
stream?: boolean;
|
||||||
|
partial_images?: number;
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An uploaded file on its way to a provider. */
|
||||||
|
export type UploadedImage = { filename: string; bytes: Uint8Array; type: string };
|
||||||
|
|
||||||
|
/** OpenAI's usage object. Stored raw so a cost computed with the wrong price can be recomputed. */
|
||||||
|
export type ImageUsage = {
|
||||||
|
total_tokens?: number;
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
input_tokens_details?: { text_tokens?: number; image_tokens?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One generated image, as the provider returned it. */
|
||||||
|
export type ProviderImage = {
|
||||||
|
/** Raw bytes. Providers hand back base64; decoding is theirs to do, so the queue only writes files. */
|
||||||
|
bytes: Uint8Array;
|
||||||
|
/** png / jpeg / webp — decides the extension on disk and the served content type. */
|
||||||
|
format: string;
|
||||||
|
revisedPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderResult = {
|
||||||
|
images: ProviderImage[];
|
||||||
|
usage?: ImageUsage;
|
||||||
|
/** Echoed back by the provider: the size, quality and background actually used. */
|
||||||
|
echoed: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An adapter: how to talk to one vendor's image API.
|
||||||
|
*
|
||||||
|
* Deliberately holds no key, no chosen model and no enabled flag. Those live in a `providers` row,
|
||||||
|
* because they are decisions rather than knowledge, and several rows share one adapter.
|
||||||
|
*/
|
||||||
|
export type ImageAdapter = {
|
||||||
|
readonly name: string;
|
||||||
|
/** For display — "OpenAI" rather than "openai", used to name providers built from it. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Models this adapter is known to serve. Advisory — an unknown model is passed through. */
|
||||||
|
readonly models: readonly string[];
|
||||||
|
readonly defaultModel: string;
|
||||||
|
/**
|
||||||
|
* The key is passed in rather than read here: it can come from the database or the environment, and
|
||||||
|
* which one is a question about configuration, not about how to talk to this provider.
|
||||||
|
*/
|
||||||
|
generate(request: ImageRequest, apiKey: string, signal?: AbortSignal): Promise<ProviderResult>;
|
||||||
|
/**
|
||||||
|
* Edit. Optional: a provider that cannot do it says so by not having it, and the route answers 400
|
||||||
|
* rather than the queue discovering it after the row is written.
|
||||||
|
*/
|
||||||
|
edit?(
|
||||||
|
request: ImageEditRequest,
|
||||||
|
images: UploadedImage[],
|
||||||
|
mask: UploadedImage | undefined,
|
||||||
|
apiKey: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ProviderResult>;
|
||||||
|
/** USD for the reported usage, or null when this model's price is not known. */
|
||||||
|
costUsd(model: string, usage: ImageUsage | undefined): string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as serializers from './serialize';
|
||||||
|
import type { Collection, CollectionWithCount, Generation } from './db';
|
||||||
|
|
||||||
|
const generation = (over: Partial<Generation> = {}): Generation =>
|
||||||
|
({
|
||||||
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
requestId: '22222222-2222-2222-2222-222222222222',
|
||||||
|
index: 0,
|
||||||
|
operation: 'generate',
|
||||||
|
status: 'succeeded',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-image-1',
|
||||||
|
prompt: 'a duck',
|
||||||
|
options: { size: '1024x1024' },
|
||||||
|
inputImages: null,
|
||||||
|
maskImage: null,
|
||||||
|
imagePath: '11111111-1111-1111-1111-111111111111.png',
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
hasAlpha: true,
|
||||||
|
opaque: false,
|
||||||
|
costUsd: '0.167075',
|
||||||
|
usage: null,
|
||||||
|
result: null,
|
||||||
|
durationMs: 40903,
|
||||||
|
error: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
...over,
|
||||||
|
}) as Generation;
|
||||||
|
|
||||||
|
const collection = (over: Partial<CollectionWithCount> = {}): CollectionWithCount =>
|
||||||
|
({
|
||||||
|
id: '33333333-3333-3333-3333-333333333333',
|
||||||
|
name: 'OfficerAppIcons',
|
||||||
|
description: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
itemCount: 6,
|
||||||
|
coverImagePath: '11111111-1111-1111-1111-111111111111.png',
|
||||||
|
...over,
|
||||||
|
}) as CollectionWithCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sample per serialiser.
|
||||||
|
*
|
||||||
|
* The test below fails if this map does not cover every function exported from ./serialize. That is
|
||||||
|
* the actual fix for the bug this file exists because of: the previous version tested the two
|
||||||
|
* generation serialisers by name, so when a collections payload started going out raw it sailed past
|
||||||
|
* a suite that was green. Now a new serialiser cannot be added without a case.
|
||||||
|
*/
|
||||||
|
const CASES: Record<string, () => unknown> = {
|
||||||
|
serializeGeneration: () => serializers.serializeGeneration(generation()),
|
||||||
|
serializeGenerationImage: () => serializers.serializeGenerationImage(generation()),
|
||||||
|
serializeCollection: () => serializers.serializeCollection(collection() as Collection),
|
||||||
|
serializeCollectionSummary: () => serializers.serializeCollectionSummary(collection()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const exported = Object.entries(serializers)
|
||||||
|
.filter(([, value]) => typeof value === 'function')
|
||||||
|
.map(([name]) => name);
|
||||||
|
|
||||||
|
const CAMEL = /[a-z][A-Z]/;
|
||||||
|
|
||||||
|
describe('every serialiser', () => {
|
||||||
|
test('has a case in this file', () => {
|
||||||
|
expect(exported.filter((name) => !(name in CASES))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(exported)('%s emits no camelCase keys', (name) => {
|
||||||
|
const output = CASES[name]!() as Record<string, unknown>;
|
||||||
|
expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(exported)('%s emits no key ending in Id, At or Path', (name) => {
|
||||||
|
// The specific spellings that leaked: createdAt, updatedAt, itemCount, coverImagePath.
|
||||||
|
const output = CASES[name]!() as Record<string, unknown>;
|
||||||
|
expect(Object.keys(output).filter((key) => /(Id|At|Path|Count|Url)$/.test(key))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shapes that must agree', () => {
|
||||||
|
test("the compact image form uses the full form's spellings", () => {
|
||||||
|
const full = Object.keys(serializers.serializeGeneration(generation()));
|
||||||
|
const compact = Object.keys(serializers.serializeGenerationImage(generation()));
|
||||||
|
expect(compact.filter((key) => !full.includes(key))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a collection summary is a collection plus counts', () => {
|
||||||
|
const base = Object.keys(serializers.serializeCollection(collection() as Collection));
|
||||||
|
const summary = Object.keys(serializers.serializeCollectionSummary(collection()));
|
||||||
|
expect(base.filter((key) => !summary.includes(key))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('values', () => {
|
||||||
|
test('cost stays a string, so totals do not round', () => {
|
||||||
|
expect(serializers.serializeGeneration(generation()).cost_usd).toBe('0.167075');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a row with no file has a null url rather than a broken one', () => {
|
||||||
|
expect(serializers.serializeGeneration(generation({ imagePath: null })).url).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty collection has a null cover, not a url to nothing', () => {
|
||||||
|
expect(serializers.serializeCollectionSummary(collection({ coverImagePath: null })).cover_url).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Collection, CollectionWithCount, Generation } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a database row becomes JSON.
|
||||||
|
*
|
||||||
|
* Every API serialiser lives in this file — a structural choice, not tidiness: it is the one place
|
||||||
|
* that can enforce snake_case across every endpoint. See the standalone repo's test for the incident
|
||||||
|
* this guarded against (`serialize.test.ts`, ported below).
|
||||||
|
*
|
||||||
|
* Snake case throughout, because the response body is deliberately OpenAI-shaped and OpenAI is snake.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/files/<name>`, relative from this plugin's own mount — NOT an absolute `PUBLIC_URL` any more.
|
||||||
|
*
|
||||||
|
* The standalone service served images unauthenticated with a durable absolute URL; inside Officer the
|
||||||
|
* route is behind the permission gate like everything else, so there is no host to bake in and no
|
||||||
|
* reason to. An API caller already holding a bearer token re-fetches this path with the same
|
||||||
|
* `Authorization` header they used to get here. The Officer frontend (which cannot set that header on
|
||||||
|
* an `<img src>`) appends its own `?token=` — see `plugins/atelier/web/lib/api.ts` — the same escape
|
||||||
|
* hatch `FileViewer` and the music plugin already use for exactly this reason.
|
||||||
|
*/
|
||||||
|
const imageUrl = (row: Generation) => (row.imagePath ? `/files/${row.imagePath}` : null);
|
||||||
|
|
||||||
|
/** What every image reports about itself, wherever it appears. */
|
||||||
|
const measured = (row: Generation) => ({
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
// `has_alpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it is
|
||||||
|
// fully opaque. Both, because an alpha channel the model never used is the failure worth catching.
|
||||||
|
has_alpha: row.hasAlpha,
|
||||||
|
opaque: row.opaque,
|
||||||
|
cost_usd: row.costUsd,
|
||||||
|
duration_ms: row.durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The compact form, for `atelier.images[]` — no prompt, which at n:10 would repeat 32000 chars ten times. */
|
||||||
|
export const serializeGenerationImage = (row: Generation) => ({
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
url: imageUrl(row),
|
||||||
|
...measured(row),
|
||||||
|
error: row.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full row, for history and collections.
|
||||||
|
*
|
||||||
|
* `prompt`, `options` and `input_images` are here deliberately and are not to be normalised or
|
||||||
|
* truncated: every disappointing result so far has been traced to what was asked for, and these three
|
||||||
|
* are what make that diagnosis possible after the fact.
|
||||||
|
*/
|
||||||
|
export const serializeGeneration = (row: Generation) => ({
|
||||||
|
id: row.id,
|
||||||
|
request_id: row.requestId,
|
||||||
|
index: row.index,
|
||||||
|
operation: row.operation,
|
||||||
|
status: row.status,
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
prompt: row.prompt,
|
||||||
|
options: row.options,
|
||||||
|
input_images: row.inputImages,
|
||||||
|
mask_image: row.maskImage,
|
||||||
|
image_path: row.imagePath,
|
||||||
|
url: imageUrl(row),
|
||||||
|
...measured(row),
|
||||||
|
error: row.error,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
started_at: row.startedAt,
|
||||||
|
finished_at: row.finishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection, as the API returns it.
|
||||||
|
*
|
||||||
|
* Drizzle hands back camelCase because that is what the schema declares in TypeScript. Returning the
|
||||||
|
* row untouched is how a camelCase field would reach the wire in a snake_case API — this is where one
|
||||||
|
* becomes the other.
|
||||||
|
*/
|
||||||
|
export const serializeCollection = (row: Collection) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
updated_at: row.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The list form, with the counts the index screen needs. */
|
||||||
|
export const serializeCollectionSummary = (row: CollectionWithCount) => ({
|
||||||
|
...serializeCollection(row),
|
||||||
|
item_count: row.itemCount,
|
||||||
|
cover_image_path: row.coverImagePath,
|
||||||
|
cover_url: row.coverImagePath ? `/files/${row.coverImagePath}` : null,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user