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