From b2049580077617a35a36c75b73991592ed265b81 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 01:50:21 +0200 Subject: [PATCH] feat(picture): fall back to gpt-image-1 when gpt-image-2 org-unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI started gating gpt-image-2 behind per-organization verification (platform.openai.com/settings/organization/general → Verify Organization, propagation up to 15 min). Unverified orgs get: "Your organization must be verified to use the model gpt-image-2" Keeps Try-On broken until the user completes that manual step. Since the edits endpoint is identical across gpt-image-1 and gpt-image-2 (same image[] multi-ref, same size/quality/n params), detect that specific rejection and retry once with gpt-image-1. - buildFormData(modelName) + callOpenAiEdits(modelName) extracted so the retry is a one-line re-invoke with the fallback model instead of a duplicated fetch block. - needsGptImage1Fallback() matches /verified to use the model/i in the error body AND checks the attempted model was actually gpt-image-2 — an explicit openai/gpt-image-1 request stays on 1. - Response now reports `model: openai/${modelUsed}` so the picture.images row records whichever model actually produced the image (matters for future re-generation / audit). Credits unchanged: our flat 3/10/25-per-quality tariff applies to all openai/* paths. Slight over-charge for the gpt-image-1 fallback until the user verifies, then gpt-image-2 takes over automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/picture/routes.ts | 82 +++++++++++++++++++------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index 966086e4e..156702963 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -341,45 +341,83 @@ routes.post('/generate-with-reference', async (c) => { // Multipart POST to OpenAI. FormData auto-sets Content-Type with a // boundary; setting it manually would break parsing on OpenAI's end. - const formData = new FormData(); - formData.append('model', openaiModel); - formData.append('prompt', prompt); - formData.append('size', size); - formData.append('quality', quality); - formData.append('n', String(effectiveBatch)); // gpt-image-* requires the array-syntax `image[]` for multi-reference // calls — a repeated plain `image` field triggers OpenAI's // `duplicate_parameter` error even though the old DALL·E edits // endpoint tolerated it. Keep `image[]` for the single-ref case too: // OpenAI accepts the array form with any cardinality ≥ 1, so there's // no need to branch here. - for (const ref of referenceBlobs) { - formData.append('image[]', ref.blob, ref.filename); + function buildFormData(modelName: string): FormData { + const fd = new FormData(); + fd.append('model', modelName); + fd.append('prompt', prompt); + fd.append('size', size); + fd.append('quality', quality); + fd.append('n', String(effectiveBatch)); + for (const ref of referenceBlobs) { + fd.append('image[]', ref.blob, ref.filename); + } + return fd; } - let generatedBuffers: ArrayBuffer[]; - try { + async function callOpenAiEdits( + modelName: string + ): Promise< + | { ok: true; data: { data?: Array<{ b64_json?: string }> } } + | { ok: false; status: number; body: string } + > { const res = await fetch('https://api.openai.com/v1/images/edits', { method: 'POST', headers: { Authorization: `Bearer ${OPENAI_API_KEY}` }, - body: formData, + body: buildFormData(modelName), }); if (!res.ok) { - const detail = await res.text().catch(() => ''); + const body = await res.text().catch(() => ''); + return { ok: false, status: res.status, body }; + } + return { ok: true, data: (await res.json()) as { data?: Array<{ b64_json?: string }> } }; + } + + // "Verify your organization to use gpt-image-2" is a known OpenAI + // rejection that stays blocked until the user completes their org + // verification (a manual step on platform.openai.com, sometimes with + // a 15-min propagation delay). Falling back to gpt-image-1 keeps the + // Try-On flow usable in the meantime — same edits endpoint, same + // `image[]` multi-reference semantics, same quality/size values. + // Only kicks in when the client requested gpt-image-2 (or left the + // default): an explicit `openai/gpt-image-1` request stays on 1. + function needsGptImage1Fallback(body: string, attemptedModel: string): boolean { + if (attemptedModel !== 'gpt-image-2') return false; + return /verified to use the model/i.test(body); + } + + let generatedBuffers: ArrayBuffer[]; + let modelUsed = openaiModel; + try { + let result = await callOpenAiEdits(openaiModel); + + if (!result.ok && needsGptImage1Fallback(result.body, openaiModel)) { + console.warn( + '[picture/generate-with-reference] gpt-image-2 unavailable (org not verified), falling back to gpt-image-1' + ); + modelUsed = 'gpt-image-1'; + result = await callOpenAiEdits('gpt-image-1'); + } + + if (!result.ok) { console.error('[picture/generate-with-reference] OpenAI returned non-ok', { - status: res.status, - statusText: res.statusText, - body: detail.slice(0, 1000), + status: result.status, + body: result.body.slice(0, 1000), refCount: referenceBlobs.length, prompt: prompt.slice(0, 120), - model: openaiModel, + model: modelUsed, size, quality, }); - return c.json({ error: 'OpenAI image edit failed', detail: detail.slice(0, 500) }, 502); + return c.json({ error: 'OpenAI image edit failed', detail: result.body.slice(0, 500) }, 502); } - const data = (await res.json()) as { data?: Array<{ b64_json?: string }> }; - const blobs = (data.data ?? []).map((d) => d.b64_json).filter((b): b is string => !!b); + + const blobs = (result.data.data ?? []).map((d) => d.b64_json).filter((b): b is string => !!b); if (blobs.length === 0) return c.json({ error: 'OpenAI returned no image data' }, 502); generatedBuffers = blobs.map((b64) => { const bin = Buffer.from(b64, 'base64'); @@ -415,10 +453,14 @@ routes.post('/generate-with-reference', async (c) => { idx++; } + // Report the model that actually produced the image, not the one + // the client asked for — matters when the gpt-image-2 fallback + // kicked in (we want the picture row's `model` metadata to match + // the real source for future re-generation / audit). return c.json({ images, prompt, - model, + model: `openai/${modelUsed}`, referenceMediaIds: refIds, mode: 'edit', // Back-compat: first image exposed at top level too, matching /generate.