Files
atelier/sidecar/providers/openai.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

173 lines
6.1 KiB
TypeScript

import type { ImageAdapter, ImageEditRequest, ImageRequest, ImageUsage, ProviderResult, UploadedImage } from './types';
const GENERATIONS_ENDPOINT = 'https://api.openai.com/v1/images/generations';
const EDITS_ENDPOINT = 'https://api.openai.com/v1/images/edits';
/**
* USD per million tokens, from OpenAI's published pricing.
*
* A table rather than a computed rate, and one that is allowed to be incomplete: a model missing here
* yields a null cost, which is honest. Guessing a price for a model we do not know produces a number
* that looks authoritative and sums into a total that is wrong. Every row also stores its raw `usage`,
* so any cost here can be recomputed later if these figures move.
*/
const PRICING: Record<string, { textInput: number; imageInput: number; imageOutput: number }> = {
'gpt-image-1': { textInput: 5, imageInput: 10, imageOutput: 40 },
'gpt-image-1-mini': { textInput: 2, imageInput: 2.5, imageOutput: 8 },
'gpt-image-1.5': { textInput: 5, imageInput: 8, imageOutput: 32 },
};
/** Only the parameters OpenAI accepts, and only those the caller actually set. */
function body(request: ImageRequest, n: number): Record<string, unknown> {
const out: Record<string, unknown> = { prompt: request.prompt, model: request.model, n };
for (const key of [
'quality',
'size',
'response_format',
'output_format',
'output_compression',
'background',
'moderation',
'style',
'user',
] as const) {
if (request[key] !== undefined) out[key] = request[key];
}
return out;
}
export const openaiAdapter: ImageAdapter = {
name: 'openai',
label: 'OpenAI',
models: ['gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'dall-e-3', 'dall-e-2'],
defaultModel: 'gpt-image-1',
async generate(request, apiKey, signal): Promise<ProviderResult> {
// One image per call, even when the caller asked for several. Each is its own row with its own
// cost and duration, and one failure out of four should not discard the three that worked.
const response = await fetch(GENERATIONS_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body(request, 1)),
signal,
});
return decodeImagesResponse(response, request.output_format, signal);
},
async edit(request, images, mask, apiKey, signal): Promise<ProviderResult> {
// multipart/form-data, because that is what the endpoint takes. Built rather than translated:
// this is a bridge, and every field goes across under the name OpenAI gave it.
const form = new FormData();
form.append('prompt', request.prompt);
form.append('n', '1');
// Repeated `image[]` — the wire form of an array of files, and what the API expects for the GPT
// image models. One image still goes as `image[]`; the endpoint accepts either.
for (const image of images) {
form.append('image[]', new Blob([image.bytes as BlobPart], { type: image.type }), image.filename);
}
if (mask) form.append('mask', new Blob([mask.bytes as BlobPart], { type: mask.type }), mask.filename);
for (const key of [
'model',
'quality',
'size',
'response_format',
'output_format',
'output_compression',
'background',
'input_fidelity',
'user',
] as const) {
const value = request[key];
if (value !== undefined) form.append(key, String(value));
}
// No content-type header: fetch sets it, with the multipart boundary that only it knows.
const response = await fetch(EDITS_ENDPOINT, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: form,
signal,
});
return decodeImagesResponse(response, request.output_format, signal);
},
costUsd(model, usage) {
const price = PRICING[model];
if (!price || !usage) return null;
const textTokens = usage.input_tokens_details?.text_tokens ?? usage.input_tokens ?? 0;
const imageTokens = usage.input_tokens_details?.image_tokens ?? 0;
const outputTokens = usage.output_tokens ?? 0;
const total =
(textTokens * price.textInput + imageTokens * price.imageInput + outputTokens * price.imageOutput) / 1_000_000;
return total.toFixed(6);
},
};
/**
* Turn OpenAI's ImagesResponse into bytes, whichever endpoint produced it.
*
* Shared by generate and edit because the response shape is identical — only the request differs.
*/
async function decodeImagesResponse(
response: Response,
requestedFormat: string | undefined,
signal?: AbortSignal,
): Promise<ProviderResult> {
if (!response.ok) {
const text = await response.text();
// Surface OpenAI's own message where there is one — "content policy violation" is worth keeping.
let detail = text.slice(0, 500);
try {
const parsed = JSON.parse(text) as { error?: { message?: string } };
if (parsed.error?.message) detail = parsed.error.message;
} catch {}
throw new Error(`OpenAI ${response.status}: ${detail}`);
}
const payload = (await response.json()) as {
data?: { b64_json?: string; url?: string; revised_prompt?: string }[];
usage?: ImageUsage;
background?: string;
output_format?: string;
size?: string;
quality?: string;
};
const format = payload.output_format ?? requestedFormat ?? 'png';
const images = await Promise.all(
(payload.data ?? []).map(async (item) => {
if (item.b64_json) {
return { bytes: Buffer.from(item.b64_json, 'base64'), format, revisedPrompt: item.revised_prompt };
}
// dall-e-2 with the default response_format hands back a URL that expires in an hour. Fetch it
// now: the whole point of Atelier is that the image outlives the request.
if (item.url) {
const bytes = new Uint8Array(await (await fetch(item.url, { signal })).arrayBuffer());
return { bytes, format, revisedPrompt: item.revised_prompt };
}
throw new Error('OpenAI returned neither b64_json nor url');
}),
);
return {
images,
usage: payload.usage,
echoed: {
background: payload.background,
output_format: payload.output_format,
size: payload.size,
quality: payload.quality,
},
};
}