manifest, thin proxy router, and the sidecar backend

ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only
adapter today), the generation queue, collections, and the database layer — kept on its own
Postgres database rather than folded into officer_db, since both collections and provider_settings
had rows on the machine this was extracted from.

what changed rather than just moved:
- images are served from an authenticated GET /files/:filename instead of an unauthenticated
  PUBLIC_URL-prefixed static route
- the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter
- provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has
  no callers left anywhere in the server code, only a migration script that also did not move

ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers
liveness.

not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not
been written, so this is not yet a discoverable plugin.
This commit is contained in:
2026-08-20 03:53:53 +00:00
commit f23ec1283b
36 changed files with 2759 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
import sharp from 'sharp';
export type ImageFacts = {
width: number | null;
height: number | null;
hasAlpha: boolean | null;
opaque: boolean | null;
};
const UNKNOWN: ImageFacts = { width: null, height: null, hasAlpha: null, opaque: null };
/**
* Measure the file that was actually written.
*
* Read from disk rather than echoed from the request, because the request is what was asked for and
* this is what arrived — and those are precisely the two things that come apart. The caller is an
* agent that cannot look at the image, so anything it is not told, it cannot know.
*
* `hasAlpha` and `opaque` answer different questions and the gap between them is the point:
*
* hasAlpha: false no alpha channel at all
* hasAlpha: true, opaque: false transparency present and used
* hasAlpha: true, opaque: true an alpha channel the model never used
*
* The third is the failure worth catching. `background: "transparent"` is accepted, the job succeeds,
* and every pixel comes back fully opaque — which for an app icon is the difference between usable and
* a black square, and is invisible from both ends without this field.
*
* Never throws. A generation that produced real bytes must not be recorded as failed because the
* measurement of it did; nulls say "not known" and the image is still there.
*/
export async function inspectImage(path: string): Promise<ImageFacts> {
try {
const image = sharp(path);
const metadata = await image.metadata();
// stats() decodes the pixels — the only way to answer "is anything actually transparent", since
// the header can only say whether a channel exists. Skipped when there is no alpha channel at
// all, where the answer is already known and the decode would be waste.
const opaque = metadata.hasAlpha ? (await image.stats()).isOpaque : true;
return {
width: metadata.width ?? null,
height: metadata.height ?? null,
hasAlpha: metadata.hasAlpha ?? null,
opaque,
};
} catch (error) {
console.error(`[inspect] could not read ${path}:`, error instanceof Error ? error.message : error);
return UNKNOWN;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { join } from 'node:path';
import { claimableGenerations, failInterrupted, getProvider, markFailed, markRunning, markSucceeded } from '../db';
import type { Generation } from '../db';
import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env';
import { adapterByName } from '../providers';
import { keyForProvider } from '../providers/credentials';
import { inspectImage } from './inspect';
import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers';
/**
* The worker pool.
*
* Postgres holds the queue, not memory. A row is the job, so a restart loses nothing that was waiting
* and `failInterrupted()` can tell the caller the truth about what was mid-flight. The only in-memory
* state is how many workers are awake, which is exactly the state that should not survive a restart.
*/
let running = 0;
let sweeping = false;
/** Wake the pool. Safe to call on every enqueue and after every completion; it settles on its own. */
export function pump(): void {
if (sweeping) return;
sweeping = true;
void sweep().finally(() => {
sweeping = false;
});
}
async function sweep(): Promise<void> {
while (running < MAX_CONCURRENT_GENERATIONS) {
const free = MAX_CONCURRENT_GENERATIONS - running;
const candidates = await claimableGenerations(free);
if (candidates.length === 0) return;
let started = 0;
for (const candidate of candidates) {
// markRunning only transitions from 'queued', so two sweeps racing for one row cannot both win
// and the image is never paid for twice.
const claimed = await markRunning(candidate.id);
if (!claimed) continue;
started += 1;
running += 1;
void run(claimed).finally(() => {
running -= 1;
pump();
});
}
// Everything on offer was taken by someone else; nothing to do until the next enqueue.
if (started === 0) return;
}
}
async function run(row: Generation): Promise<void> {
const started = Date.now();
try {
const adapter = adapterByName(row.provider);
if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`);
// The provider row is looked up by id, but the adapter and model come from the ROW's own columns:
// a provider renamed or repointed after this was queued must not change what gets generated.
const provider = row.providerId ? await getProvider(row.providerId) : undefined;
const apiKey = provider
? await keyForProvider(provider)
: await keyForProvider({ adapter: row.provider, apiKeyEncrypted: null, name: row.provider } as never);
if (!apiKey) throw new Error(`No API key for adapter "${row.provider}"`);
const result =
row.operation === 'edit'
? await runEdit(row, adapter, apiKey)
: await adapter.generate(
{ ...(row.options ?? {}), prompt: row.prompt, model: row.model, n: 1 } as ImageRequest,
apiKey,
);
const image = result.images[0];
if (!image) throw new Error('Provider returned no image');
// The row id is the filename: one lookup gets you from a served URL back to the prompt that made it.
const filename = `${row.id}.${image.format}`;
const path = join(IMAGES_DIR, filename);
await Bun.write(path, image.bytes);
// Measured after the write, from the file itself.
const facts = await inspectImage(path);
await markSucceeded(row.id, {
imagePath: filename,
...facts,
costUsd: adapter.costUsd(row.model, result.usage),
usage: result.usage ?? null,
result: { ...result.echoed, revised_prompt: image.revisedPrompt },
durationMs: Date.now() - started,
});
} catch (error) {
await markFailed(row.id, error instanceof Error ? error.message : String(error), Date.now() - started);
}
}
const MIME: Record<string, string> = {
'.png': 'image/png',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};
/** Read the uploads back off disk and hand them to the provider. */
async function runEdit(row: Generation, adapter: NonNullable<ReturnType<typeof adapterByName>>, apiKey: string) {
if (!adapter.edit) throw new Error(`Adapter "${adapter.name}" cannot edit images`);
const load = async (name: string): Promise<UploadedImage> => {
const file = Bun.file(join(INPUTS_DIR, name));
if (!(await file.exists())) throw new Error(`Input image ${name} is missing`);
const extension = name.slice(name.lastIndexOf('.')).toLowerCase();
return { filename: name, bytes: new Uint8Array(await file.arrayBuffer()), type: MIME[extension] ?? 'image/png' };
};
const images = await Promise.all((row.inputImages ?? []).map(load));
const mask = row.maskImage ? await load(row.maskImage) : undefined;
const request = { ...(row.options ?? {}), prompt: row.prompt, model: row.model } as ImageEditRequest;
return adapter.edit(request, images, mask, apiKey);
}
/**
* Called once at boot, before the pool starts.
*
* Rows left `running` have no worker behind them — whatever was going to finish them died with the
* last process. Left alone they stay running forever and a caller polling one waits on an answer that
* is never coming.
*/
export async function startQueue(): Promise<void> {
// Once per process, matching the standalone repo's own guard against `bun --hot` double-starting the
// sweep loop. This process is not hot-reloaded under PM2, but the guard is free and correct either way.
const globalForQueue = globalThis as unknown as { __atelierQueueStarted?: boolean };
if (globalForQueue.__atelierQueueStarted) return;
globalForQueue.__atelierQueueStarted = true;
const interrupted = await failInterrupted();
if (interrupted > 0) console.log(`[atelier:queue] failed ${interrupted} generation(s) interrupted by a restart`);
pump();
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test';
import type { Generation } from '../db';
import { imagesResponse, isSettled, jobStatus } from './response';
const row = (over: Partial<Generation> = {}): Generation =>
({
id: '11111111-1111-1111-1111-111111111111',
requestId: '22222222-2222-2222-2222-222222222222',
index: 0,
status: 'succeeded',
provider: 'openai',
model: 'gpt-image-1',
prompt: 'a voxel duck',
options: {},
imagePath: '11111111-1111-1111-1111-111111111111.png',
costUsd: '0.010000',
usage: {
total_tokens: 100,
input_tokens: 20,
output_tokens: 80,
input_tokens_details: { text_tokens: 20, image_tokens: 0 },
},
result: {},
durationMs: 4200,
error: null,
createdAt: new Date('2026-08-16T12:00:00Z'),
startedAt: new Date('2026-08-16T12:00:01Z'),
finishedAt: new Date('2026-08-16T12:00:05Z'),
...over,
}) as Generation;
describe('jobStatus', () => {
test('running beats queued', () =>
expect(jobStatus([row({ status: 'queued' }), row({ status: 'running' })])).toBe('running'));
test('queued while any is waiting', () => expect(jobStatus([row(), row({ status: 'queued' })])).toBe('queued'));
test('succeeded when all did', () => expect(jobStatus([row(), row()])).toBe('succeeded'));
test('failed when all did', () =>
expect(jobStatus([row({ status: 'failed' }), row({ status: 'failed' })])).toBe('failed'));
// The case n>1 exists for: three images made, one refused. Calling that "failed" discards work that
// was paid for; calling it "succeeded" hides a failure.
test('partial when some failed and some did not', () =>
expect(jobStatus([row(), row({ status: 'failed' })])).toBe('partial'));
});
describe('isSettled', () => {
test.each(['succeeded', 'failed', 'partial'] as const)('%s is settled', (s) => expect(isSettled(s)).toBe(true));
test.each(['queued', 'running'] as const)('%s is not', (s) => expect(isSettled(s)).toBe(false));
});
describe('imagesResponse', () => {
test('shapes an OpenAI response, with url populated', async () => {
const body = await imagesResponse([row()], false);
expect(body.created).toBe(Math.floor(new Date('2026-08-16T12:00:00Z').getTime() / 1000));
expect(body.data).toHaveLength(1);
// The divergence that is the point of Atelier: OpenAI never returns url for GPT image models.
expect(body.data[0]!.url).toMatch(/\/files\/11111111-1111-1111-1111-111111111111\.png$/);
expect(body.data[0]).not.toHaveProperty('b64_json');
});
test('omits failed images from data but keeps them in the atelier block', async () => {
const body = await imagesResponse([row(), row({ status: 'failed', imagePath: null, error: 'refused' })], false);
expect(body.data).toHaveLength(1);
expect(body.atelier.images).toHaveLength(2);
expect(body.atelier.status).toBe('partial');
expect(body.atelier.images[1]!.error).toBe('refused');
});
test('sums usage and cost across images', async () => {
const body = await imagesResponse([row(), row({ costUsd: '0.020000' })], false);
expect(body.usage.total_tokens).toBe(200);
expect(body.usage.output_tokens).toBe(160);
expect(body.atelier.cost_usd).toBe('0.030000');
});
test('reports revised_prompt when the provider rewrote it', async () => {
const body = await imagesResponse([row({ result: { revised_prompt: 'a cheerful voxel duck' } })], false);
expect(body.data[0]!.revised_prompt).toBe('a cheerful voxel duck');
});
});
+109
View File
@@ -0,0 +1,109 @@
import { join } from 'node:path';
import type { Generation } from '../db';
import { IMAGES_DIR } from '../env';
import { serializeGenerationImage } from '../serialize';
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
/**
* The status of a request, from the rows of its images.
*
* `partial` exists because `n` images can end differently: three succeed and one trips the content
* filter. Reporting that as `failed` throws away three images that exist and were paid for, and
* reporting it as `succeeded` hides a failure the caller should see.
*/
export function jobStatus(rows: Generation[]): JobStatus {
if (rows.some((row) => row.status === 'running')) return 'running';
if (rows.some((row) => row.status === 'queued')) return 'queued';
const failed = rows.filter((row) => row.status === 'failed').length;
if (failed === 0) return 'succeeded';
if (failed === rows.length) return 'failed';
return 'partial';
}
export const isSettled = (status: JobStatus) => status === 'succeeded' || status === 'failed' || status === 'partial';
/** `/files/<name>`, relative from this plugin's own mount. See serialize.ts for why. */
export const imageUrl = (row: Generation) => `/files/${row.imagePath}`;
/**
* OpenAI's `ImagesResponse`, built from our rows.
*
* Deliberate divergence from OpenAI's own shape, and the reason Atelier exists: `url` is always
* populated, including for the GPT image models, where OpenAI returns base64 only. `b64_json` is
* included only when the caller asked for it, so the common response stays small.
*/
export async function imagesResponse(rows: Generation[], includeBase64: boolean) {
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.imagePath);
const data = await Promise.all(
succeeded.map(async (row) => {
const item: Record<string, unknown> = { url: imageUrl(row) };
const revised = (row.result as { revised_prompt?: string } | null)?.revised_prompt;
if (revised) item.revised_prompt = revised;
if (includeBase64) {
const bytes = await Bun.file(join(IMAGES_DIR, row.imagePath!)).arrayBuffer();
item.b64_json = Buffer.from(bytes).toString('base64');
}
return item;
}),
);
const first = rows[0];
const echoed = (first?.result ?? {}) as Record<string, unknown>;
const totalCost = rows.reduce((sum, row) => sum + Number(row.costUsd ?? 0), 0);
return {
created: Math.floor((first?.createdAt?.getTime() ?? Date.now()) / 1000),
data,
...(echoed.background ? { background: echoed.background } : {}),
...(echoed.output_format ? { output_format: echoed.output_format } : {}),
...(echoed.size ? { size: echoed.size } : {}),
...(echoed.quality ? { quality: echoed.quality } : {}),
usage: mergeUsage(rows),
// Not OpenAI's, and namespaced so it cannot collide with a field they add later.
atelier: {
id: first?.requestId,
status: jobStatus(rows),
model: first?.model,
provider: first?.provider,
cost_usd: totalCost > 0 ? totalCost.toFixed(6) : null,
duration_ms: rows.reduce((max, row) => Math.max(max, row.durationMs ?? 0), 0),
// Through the shared serializer, so a field here and the same field in /images cannot drift
// into two spellings again.
images: rows.map(serializeGenerationImage),
},
};
}
/** Sum the per-image usage back into one object shaped like OpenAI's. */
function mergeUsage(rows: Generation[]) {
const totals = { total_tokens: 0, input_tokens: 0, output_tokens: 0, text_tokens: 0, image_tokens: 0 };
for (const row of rows) {
const usage = row.usage as {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
input_tokens_details?: Record<string, number>;
} | null;
if (!usage) continue;
totals.total_tokens += usage.total_tokens ?? 0;
totals.input_tokens += usage.input_tokens ?? 0;
totals.output_tokens += usage.output_tokens ?? 0;
totals.text_tokens += usage.input_tokens_details?.text_tokens ?? 0;
totals.image_tokens += usage.input_tokens_details?.image_tokens ?? 0;
}
return {
total_tokens: totals.total_tokens,
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
input_tokens_details: { text_tokens: totals.text_tokens, image_tokens: totals.image_tokens },
};
}
+180
View File
@@ -0,0 +1,180 @@
import type { ImageEditRequest, ImageRequest } from '../providers';
/**
* Validate a request body against OpenAI's `POST /v1/images/generations` schema.
*
* Every parameter they accept is accepted here, with their enums and their bounds. What is NOT done is
* rejecting a combination that only some models allow — `style` on `gpt-image-1`, `background` on
* `dall-e-3`. Those rules change per model and per release, and a stale copy of them here would refuse
* requests the provider would have served. The provider is the authority; its error comes back intact.
*/
const QUALITIES = ['auto', 'high', 'medium', 'low', 'hd', 'standard'] as const;
const RESPONSE_FORMATS = ['url', 'b64_json'] as const;
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const;
const BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
const MODERATIONS = ['low', 'auto'] as const;
const STYLES = ['vivid', 'natural'] as const;
/** `auto`, one of the named sizes, or `WIDTHxHEIGHT` — which gpt-image-2 accepts arbitrarily. */
const SIZE_PATTERN = /^(auto|\d{2,5}x\d{2,5})$/;
export type ValidationError = { message: string; param?: string };
export function validateImageRequest(input: unknown): { request: ImageRequest } | { error: ValidationError } {
if (typeof input !== 'object' || input === null) {
return { error: { message: 'Request body must be a JSON object.' } };
}
const body = input as Record<string, unknown>;
const request: ImageRequest = { prompt: '' };
if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
return { error: { message: "'prompt' is required and must be a non-empty string.", param: 'prompt' } };
}
request.prompt = body.prompt;
if (body.model !== undefined && body.model !== null) {
if (typeof body.model !== 'string') return { error: { message: "'model' must be a string.", param: 'model' } };
request.model = body.model;
}
if (body.n !== undefined && body.n !== null) {
if (typeof body.n !== 'number' || !Number.isInteger(body.n) || body.n < 1 || body.n > 10) {
return { error: { message: "'n' must be an integer between 1 and 10.", param: 'n' } };
}
request.n = body.n;
}
if (body.size !== undefined && body.size !== null) {
if (typeof body.size !== 'string' || !SIZE_PATTERN.test(body.size)) {
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
}
request.size = body.size;
}
if (body.output_compression !== undefined && body.output_compression !== null) {
const value = body.output_compression;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) {
return {
error: { message: "'output_compression' must be an integer between 0 and 100.", param: 'output_compression' },
};
}
request.output_compression = value;
}
if (body.partial_images !== undefined && body.partial_images !== null) {
const value = body.partial_images;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 3) {
return { error: { message: "'partial_images' must be an integer between 0 and 3.", param: 'partial_images' } };
}
request.partial_images = value;
}
if (body.stream !== undefined && body.stream !== null) {
if (typeof body.stream !== 'boolean') {
return { error: { message: "'stream' must be a boolean.", param: 'stream' } };
}
// Accepted and ignored. Generation here is a job the caller polls, so there is no response stream
// to put partial images on; saying so beats pretending the flag did something.
request.stream = body.stream;
}
if (body.user !== undefined && body.user !== null) {
if (typeof body.user !== 'string') return { error: { message: "'user' must be a string.", param: 'user' } };
request.user = body.user;
}
const enums = [
['quality', QUALITIES],
['response_format', RESPONSE_FORMATS],
['output_format', OUTPUT_FORMATS],
['background', BACKGROUNDS],
['moderation', MODERATIONS],
['style', STYLES],
] as const;
for (const [param, allowed] of enums) {
const value = body[param];
if (value === undefined || value === null) continue;
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
return { request };
}
// ── Edits ──
//
// A separate validator, not a flag on the one above, because OpenAI's two endpoints genuinely differ:
// edits take `input_fidelity`, have no `style` or `moderation`, and their `quality` has no `hd`.
// Sharing one validator would mean accepting each endpoint's parameters on the other.
const EDIT_QUALITIES = ['auto', 'high', 'medium', 'low', 'standard'] as const;
const INPUT_FIDELITIES = ['high', 'low'] as const;
/** Fields arrive as form strings, so numbers and booleans come in as text. */
export function validateImageEditRequest(
fields: Record<string, string>,
): { request: ImageEditRequest } | { error: ValidationError } {
const request: ImageEditRequest = { prompt: '' };
if (!fields.prompt) {
return { error: { message: "'prompt' is required.", param: 'prompt' } };
}
request.prompt = fields.prompt;
if (fields.model !== undefined) request.model = fields.model;
if (fields.user !== undefined) request.user = fields.user;
const integers = [
['n', 1, 10],
['output_compression', 0, 100],
['partial_images', 0, 3],
] as const;
for (const [param, min, max] of integers) {
const raw = fields[param];
if (raw === undefined) continue;
const value = Number(raw);
if (!Number.isInteger(value) || value < min || value > max) {
return { error: { message: `'${param}' must be an integer between ${min} and ${max}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
if (fields.size !== undefined) {
if (!SIZE_PATTERN.test(fields.size)) {
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
}
request.size = fields.size;
}
if (fields.stream !== undefined) {
if (fields.stream !== 'true' && fields.stream !== 'false') {
return { error: { message: "'stream' must be true or false.", param: 'stream' } };
}
request.stream = fields.stream === 'true';
}
const enums = [
['quality', EDIT_QUALITIES],
['response_format', RESPONSE_FORMATS],
['output_format', OUTPUT_FORMATS],
['background', BACKGROUNDS],
['input_fidelity', INPUT_FIDELITIES],
] as const;
for (const [param, allowed] of enums) {
const value = fields[param];
if (value === undefined) continue;
if (!(allowed as readonly string[]).includes(value)) {
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
return { request };
}