Files
atelier/sidecar/generations/response.ts
T
pastilhas f23ec1283b 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.
2026-08-20 03:53:53 +00:00

110 lines
4.3 KiB
TypeScript

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 },
};
}