commit f23ec1283b1b436aa1cef1da039b63d2d4af4bed Author: André Padez Date: Thu Aug 20 03:53:53 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..37ca221 --- /dev/null +++ b/api/router.ts @@ -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; diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..c653fef --- /dev/null +++ b/manifest.ts @@ -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', + }, + ], +}; diff --git a/sidecar/api/collections.ts b/sidecar/api/collections.ts new file mode 100644 index 0000000..dce08aa --- /dev/null +++ b/sidecar/api/collections.ts @@ -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) } }); +}); diff --git a/sidecar/api/files.ts b/sidecar/api/files.ts new file mode 100644 index 0000000..d271dc0 --- /dev/null +++ b/sidecar/api/files.ts @@ -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' } }); +}); diff --git a/sidecar/api/images.ts b/sidecar/api/images.ts new file mode 100644 index 0000000..61ea2c8 --- /dev/null +++ b/sidecar/api/images.ts @@ -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=` 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 = {}; + 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, + ); +}); diff --git a/sidecar/api/providers.ts b/sidecar/api/providers.ts new file mode 100644 index 0000000..2fdc1e2 --- /dev/null +++ b/sidecar/api/providers.ts @@ -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 | null; + if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400); + + const adapterName = typeof body.adapter === 'string' ? body.adapter : ''; + const adapter = adapterByName(adapterName); + if (!adapter) { + return ctx.json(openaiError(`Unknown adapter "${adapterName}".`, 'invalid_request_error', 'adapter'), 400); + } + + const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : adapter.defaultModel; + + // Named after what it is, unless the caller had a better idea. Two providers on one adapter need + // distinguishable names, and "OpenAI" alone stops being one the moment there are two. + const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : `${adapter.label} · ${model}`; + if (await getProviderByName(name)) { + return ctx.json(openaiError(`A provider named "${name}" already exists.`, 'invalid_request_error', 'name'), 409); + } + + const row = await createProvider({ + name, + adapter: adapter.name, + model, + apiKeyEncrypted: typeof body.apiKey === 'string' && body.apiKey.trim() ? encrypt(body.apiKey.trim()) : null, + enabled: body.enabled === undefined ? true : Boolean(body.enabled), + isDefault: Boolean(body.isDefault), + }); + + return ctx.json({ data: await providerStatuses(), created: row.id }, 201); +}); + +providersRouter.patch('/:id', async (ctx) => { + const id = ctx.req.param('id'); + if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404); + + const body = (await ctx.req.json().catch(() => null)) as Record | null; + if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400); + + const values: Record = {}; + + // Undefined means "leave alone" and null means "clear" — the UI sends `enabled` on its own when + // toggling, and a naive assignment would blank the credential every time someone flipped a switch. + if (body.apiKey !== undefined) { + if (body.apiKey === null || body.apiKey === '') values.apiKeyEncrypted = null; + else if (typeof body.apiKey === 'string') values.apiKeyEncrypted = encrypt(body.apiKey.trim()); + else return ctx.json(openaiError("'apiKey' must be a string or null.", 'invalid_request_error', 'apiKey'), 400); + } + + if (body.model !== undefined) { + if (typeof body.model !== 'string' || !body.model.trim()) { + return ctx.json(openaiError("'model' must be a non-empty string.", 'invalid_request_error', 'model'), 400); + } + values.model = body.model.trim(); + } + + if (body.name !== undefined) { + if (typeof body.name !== 'string' || !body.name.trim()) { + return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400); + } + const clash = await getProviderByName(body.name.trim()); + if (clash && clash.id !== id) { + return ctx.json( + openaiError(`A provider named "${body.name.trim()}" already exists.`, 'invalid_request_error', 'name'), + 409, + ); + } + values.name = body.name.trim(); + } + + if (body.enabled !== undefined) values.enabled = Boolean(body.enabled); + if (body.isDefault !== undefined) values.isDefault = Boolean(body.isDefault); + + if (Object.keys(values).length === 0) + return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400); + + await updateProvider(id, values); + return ctx.json({ data: await providerStatuses() }); +}); + +/** Removes the configuration. Images it made keep their adapter and model as text and are unaffected. */ +providersRouter.delete('/:id', async (ctx) => { + const id = ctx.req.param('id'); + if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404); + + await deleteProvider(id); + return ctx.json({ data: await providerStatuses() }); +}); diff --git a/sidecar/create-router.ts b/sidecar/create-router.ts new file mode 100644 index 0000000..eb65230 --- /dev/null +++ b/sidecar/create-router.ts @@ -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 }>(); diff --git a/sidecar/crypto.ts b/sidecar/crypto.ts new file mode 100644 index 0000000..4483ce0 --- /dev/null +++ b/sidecar/crypto.ts @@ -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)}`; +} diff --git a/sidecar/db/collections/index.ts b/sidecar/db/collections/index.ts new file mode 100644 index 0000000..eb27163 --- /dev/null +++ b/sidecar/db/collections/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './queries'; diff --git a/sidecar/db/collections/queries.ts b/sidecar/db/collections/queries.ts new file mode 100644 index 0000000..c152f82 --- /dev/null +++ b/sidecar/db/collections/queries.ts @@ -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 { + const rows = await db + .select({ + id: collections.id, + name: collections.name, + description: collections.description, + createdAt: collections.createdAt, + updatedAt: collections.updatedAt, + itemCount: sql`count(${collectionItems.generationId})::int`, + // The newest image that actually has a file — a failed generation makes a poor cover. + coverImagePath: sql` + (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 { + const [row] = await db.select().from(collections).where(eq(collections.id, id)).limit(1); + return row; +} + +export async function getCollectionByName(name: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 = {}; + for (const row of rows) (map[row.generationId] ??= []).push(row.collectionId); + return map; +} diff --git a/sidecar/db/collections/schema.ts b/sidecar/db/collections/schema.ts new file mode 100644 index 0000000..23e3d16 --- /dev/null +++ b/sidecar/db/collections/schema.ts @@ -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), + ], +); diff --git a/sidecar/db/db.ts b/sidecar/db/db.ts new file mode 100644 index 0000000..b36a21c --- /dev/null +++ b/sidecar/db/db.ts @@ -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 }; + +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 { + 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)); + } + } +} diff --git a/sidecar/db/generations/index.ts b/sidecar/db/generations/index.ts new file mode 100644 index 0000000..eb27163 --- /dev/null +++ b/sidecar/db/generations/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './queries'; diff --git a/sidecar/db/generations/queries.ts b/sidecar/db/generations/queries.ts new file mode 100644 index 0000000..4568021 --- /dev/null +++ b/sidecar/db/generations/queries.ts @@ -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 { + const [row] = await db.insert(generations).values(values).returning(); + return row!; +} + +export async function getGeneration(id: string): Promise { + const [row] = await db.select().from(generations).where(eq(generations.id, id)).limit(1); + return row; +} + +export async function listGenerations(limit = 50): Promise { + 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 { + return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index)); +} + +export async function createRequest(rows: NewGeneration[]): Promise { + 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 { + 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 { + 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 | null; + result?: Record | null; + durationMs: number; + }, +): Promise { + 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 { + 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 { + 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; +} diff --git a/sidecar/db/generations/schema.ts b/sidecar/db/generations/schema.ts new file mode 100644 index 0000000..f6b367e --- /dev/null +++ b/sidecar/db/generations/schema.ts @@ -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(), + + status: text('status').notNull().default('queued').$type(), + + /** + * 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>(), + + /** + * 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(), + 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>(), + + /** What the provider said back, minus the image bytes — `revised_prompt`, echoed size and quality. */ + result: jsonb('result').$type>(), + + 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), + ], +); diff --git a/sidecar/db/index.ts b/sidecar/db/index.ts new file mode 100644 index 0000000..5381313 --- /dev/null +++ b/sidecar/db/index.ts @@ -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'; diff --git a/sidecar/db/providers/index.ts b/sidecar/db/providers/index.ts new file mode 100644 index 0000000..eb27163 --- /dev/null +++ b/sidecar/db/providers/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './queries'; diff --git a/sidecar/db/providers/queries.ts b/sidecar/db/providers/queries.ts new file mode 100644 index 0000000..eb9f0fe --- /dev/null +++ b/sidecar/db/providers/queries.ts @@ -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 { + return db.select().from(providers).orderBy(asc(providers.name)); +} + +export async function getProvider(id: string): Promise { + const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1); + return row; +} + +export async function getProviderByName(name: string): Promise { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + await db + .update(providers) + .set({ isDefault: false }) + .where(and(eq(providers.isDefault, true), ne(providers.id, keepId))); +} diff --git a/sidecar/db/providers/schema.ts b/sidecar/db/providers/schema.ts new file mode 100644 index 0000000..1e9a840 --- /dev/null +++ b/sidecar/db/providers/schema.ts @@ -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), + ], +); diff --git a/sidecar/db/schema.ts b/sidecar/db/schema.ts new file mode 100644 index 0000000..18cf027 --- /dev/null +++ b/sidecar/db/schema.ts @@ -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 _-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'; diff --git a/sidecar/env.ts b/sidecar/env.ts new file mode 100644 index 0000000..028a5ca --- /dev/null +++ b/sidecar/env.ts @@ -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]; diff --git a/sidecar/errors.ts b/sidecar/errors.ts new file mode 100644 index 0000000..68364cd --- /dev/null +++ b/sidecar/errors.ts @@ -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; diff --git a/sidecar/generations/inspect.ts b/sidecar/generations/inspect.ts new file mode 100644 index 0000000..5b83628 --- /dev/null +++ b/sidecar/generations/inspect.ts @@ -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 { + 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; + } +} diff --git a/sidecar/generations/queue.ts b/sidecar/generations/queue.ts new file mode 100644 index 0000000..612c498 --- /dev/null +++ b/sidecar/generations/queue.ts @@ -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 { + 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 { + 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 = { + '.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>, apiKey: string) { + if (!adapter.edit) throw new Error(`Adapter "${adapter.name}" cannot edit images`); + + const load = async (name: string): Promise => { + 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 { + // 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(); +} diff --git a/sidecar/generations/response.test.ts b/sidecar/generations/response.test.ts new file mode 100644 index 0000000..b9d340f --- /dev/null +++ b/sidecar/generations/response.test.ts @@ -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 => + ({ + 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'); + }); +}); diff --git a/sidecar/generations/response.ts b/sidecar/generations/response.ts new file mode 100644 index 0000000..ae0731f --- /dev/null +++ b/sidecar/generations/response.ts @@ -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/`, 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 = { 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; + + 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; + } | 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 }, + }; +} diff --git a/sidecar/generations/validate.ts b/sidecar/generations/validate.ts new file mode 100644 index 0000000..804957e --- /dev/null +++ b/sidecar/generations/validate.ts @@ -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; + 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)[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, +): { 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)[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)[param] = value; + } + + return { request }; +} diff --git a/sidecar/hono.ts b/sidecar/hono.ts new file mode 100644 index 0000000..886bc07 --- /dev/null +++ b/sidecar/hono.ts @@ -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; +} diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..1b0741c --- /dev/null +++ b/sidecar/index.ts @@ -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).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')); diff --git a/sidecar/providers/credentials.ts b/sidecar/providers/credentials.ts new file mode 100644 index 0000000..d52a432 --- /dev/null +++ b/sidecar/providers/credentials.ts @@ -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 { + 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 `_API_KEY` in `.env` so an install that predates the secret store + * keeps working unchanged. + * + * Keyed per-adapter (`atelier--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 { + 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', + })); diff --git a/sidecar/providers/index.ts b/sidecar/providers/index.ts new file mode 100644 index 0000000..58a762e --- /dev/null +++ b/sidecar/providers/index.ts @@ -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); +} diff --git a/sidecar/providers/openai.ts b/sidecar/providers/openai.ts new file mode 100644 index 0000000..da702be --- /dev/null +++ b/sidecar/providers/openai.ts @@ -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 = { + '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 { + const out: Record = { 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 { + // 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 { + // 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 { + 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, + }, + }; +} diff --git a/sidecar/providers/types.ts b/sidecar/providers/types.ts new file mode 100644 index 0000000..f22f20c --- /dev/null +++ b/sidecar/providers/types.ts @@ -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; +}; + +/** + * 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; + /** + * 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; + /** USD for the reported usage, or null when this model's price is not known. */ + costUsd(model: string, usage: ImageUsage | undefined): string | null; +}; diff --git a/sidecar/serialize.test.ts b/sidecar/serialize.test.ts new file mode 100644 index 0000000..3196789 --- /dev/null +++ b/sidecar/serialize.test.ts @@ -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 => + ({ + 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 => + ({ + 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 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; + 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; + 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(); + }); +}); diff --git a/sidecar/serialize.ts b/sidecar/serialize.ts new file mode 100644 index 0000000..246c2a3 --- /dev/null +++ b/sidecar/serialize.ts @@ -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/`, 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 ``) 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, +});