diff --git a/changelog.d/fixes/12362-image-gen-response-wrapper.md b/changelog.d/fixes/12362-image-gen-response-wrapper.md new file mode 100644 index 0000000000..3de7c535aa --- /dev/null +++ b/changelog.d/fixes/12362-image-gen-response-wrapper.md @@ -0,0 +1 @@ +- **fix(api):** keep the `{created, data}` wrapper on combo-routed `/v1/images/generations` responses and default Codex image results to `b64_json` on both `/v1/images/generations` and `/v1/images/edits` so Codex CLI's built-in `image_gen` can decode them ([#12268](https://github.com/diegosouzapw/OmniRoute/issues/12268)) diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index c9175a2612..62a9488565 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -2679,7 +2679,11 @@ async function handleCodexImageGeneration({ } } - const wantsUrl = body.response_format !== "b64_json"; + // OpenAI returns b64_json for the gpt-image-* family and reserves `url` for + // fetchable HTTPS links, so clients that omit response_format (Codex CLI's + // built-in image_gen among them) expect the bytes in b64_json. Only emit the + // data: URI when the caller explicitly asks for `url` (#12268). + const wantsUrl = body.response_format === "url"; const data = wantsUrl ? collected.map((item) => ({ url: `data:image/png;base64,${item.b64_json}`, diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts index 0ff784ce83..650829d2b2 100644 --- a/open-sse/services/imageCombo.ts +++ b/open-sse/services/imageCombo.ts @@ -57,19 +57,13 @@ export async function executeImageCombo( const combo = await getComboByName(comboName); if (!combo) { // Model name is not a combo; the caller should handle this as a direct model - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `Combo not found: ${comboName}` - ); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); } const allCombos = await getCombos(); const targets = resolveComboTargets(combo as never, allCombos as never); if (!targets || targets.length === 0) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `Combo "${comboName}" has no usable targets` - ); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); } // 2. Filter to images-capable targets @@ -154,10 +148,7 @@ export async function executeImageCombo( // Terminal failures (400 bad model, 403 banned, etc.) — stop iterating // Non-terminal failures (429, 5xx) — try next target if (status === 400 || status === 403 || status === 401) { - return errorResponse( - status, - `[${targetProvider}] ${error}` - ); + return errorResponse(status, `[${targetProvider}] ${error}`); } lastError = { status, error: `[${targetProvider}] ${error}` }; @@ -166,18 +157,12 @@ export async function executeImageCombo( // 4. Build response if (successResult) { - const n = Math.max( - Number(body.n) || 1, - ( - successResult.data as { data?: { data?: unknown[] } } - ).data?.data?.length || 0 - ); - const costUsd = await calculateModalCost( - "image", - selectedProvider, - selectedModel, - { n } - ); + // handleImageGeneration() already returns the public OpenAI images payload + // ({ created, data: [...] }); count the images at that level (#12268). + const payload = successResult.data as { created?: number; data?: unknown[] } | unknown[]; + const images = Array.isArray(payload) ? payload : payload?.data; + const n = Math.max(Number(body.n) || 1, images?.length || 0); + const costUsd = await calculateModalCost("image", selectedProvider, selectedModel, { n }); const headers = new Headers({ "Content-Type": "application/json" }); attachOmniRouteMetaHeaders(headers, { @@ -190,10 +175,13 @@ export async function executeImageCombo( fallbackAttempts: fallbackCount, }); - return new Response( - JSON.stringify((successResult.data as { data: unknown }).data), - { status: 200, headers } - ); + // Return the handler payload unchanged so the combo path matches the + // direct-model path byte-for-byte; re-wrap only if a handler ever yields + // a bare array (#12268). + const responseBody = Array.isArray(payload) + ? { created: Math.floor(Date.now() / 1000), data: payload } + : payload; + return new Response(JSON.stringify(responseBody), { status: 200, headers }); } // All targets failed — return the last error @@ -205,4 +193,4 @@ export async function executeImageCombo( status: lastError?.status || 502, headers: { "Content-Type": "application/json" }, }); -} \ No newline at end of file +} diff --git a/tests/unit/combo/image-combo.test.ts b/tests/unit/combo/image-combo.test.ts index d455875b96..3d1e0946d1 100644 --- a/tests/unit/combo/image-combo.test.ts +++ b/tests/unit/combo/image-combo.test.ts @@ -23,6 +23,7 @@ fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); const core = await import("@/lib/db/core.ts"); const { createCombo } = await import("@/lib/db/combos"); +const { createProviderConnection } = await import("@/lib/db/providers"); const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo"); type LogEntry = { level: string; tag: unknown; msg: unknown }; @@ -283,3 +284,69 @@ test("all error responses from executeImageCombo sanitize stack traces", async ( ); } }); + +// --------------------------------------------------------------------------- +// Success path — public response shape (#12268) +// --------------------------------------------------------------------------- + +function buildCodexSSE(items: Array>): string { + const frames = items.map((item) => JSON.stringify({ type: "response.output_item.done", item })); + return frames.map((frame) => `event: response.output_item.done\ndata: ${frame}\n`).join("\n"); +} + +test("combo success keeps the OpenAI {created, data} wrapper and Codex defaults to b64_json (#12268)", async () => { + // Codex CLI hardcodes the model name `gpt-image-2`; a combo is what lets it + // reach a codex target. The combo response must match the direct-model + // response shape byte-for-byte or the client aborts while decoding `created`. + await createProviderConnection({ + provider: "codex", + authType: "apikey", + apiKey: "codex-token", + name: "codex-image-combo", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await createCombo({ + name: "gpt-image-2", + strategy: "priority", + models: ["codex/gpt-5.6-sol"], + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + buildCodexSSE([ + { + type: "image_generation_call", + id: "ig_combo_1", + status: "completed", + revised_prompt: "a green tree icon", + result: "aVZCT1J3MEtHZ28=", + }, + ]), + { status: 200, headers: { "content-type": "text/event-stream" } } + ); + + try { + const log = createLog(); + const response = await executeImageCombo( + "gpt-image-2", + { model: "gpt-image-2", prompt: "a green tree icon, white background, minimal flat" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.ok(!Array.isArray(body), "combo path must not return a bare array"); + assert.equal(typeof body.created, "number"); + assert.ok(Array.isArray(body.data)); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].b64_json, "aVZCT1J3MEtHZ28="); + assert.equal(body.data[0].url, undefined); + assert.equal(body.data[0].revised_prompt, "a green tree icon"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 1d7a18a3e3..9842b956cd 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -1843,7 +1843,7 @@ test("handleImageGeneration routes codex image requests through /responses with } }); -test("handleImageGeneration (codex) returns a data URL when response_format is not b64_json", async () => { +test("handleImageGeneration (codex) defaults to b64_json when response_format is unset (#12268)", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { const sse = buildCodexSSE([ @@ -1859,6 +1859,29 @@ test("handleImageGeneration (codex) returns a data URL when response_format is n log: null, }); assert.equal(result.success, true); + assert.equal(result.data.data[0].b64_json, "YWJjZA=="); + assert.equal(result.data.data[0].url, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) returns a data URL only when response_format is url", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const sse = buildCodexSSE([ + { type: "image_generation_call", id: "ig_3", status: "completed", result: "YWJjZA==" }, + ]); + return new Response(sse, { status: 200 }); + }; + + try { + const result = await handleImageGeneration({ + body: { model: "cx/gpt-5.6-sol", prompt: "kitten", response_format: "url" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, true); assert.equal(result.data.data[0].url, "data:image/png;base64,YWJjZA=="); assert.equal(result.data.data[0].b64_json, undefined); } finally { diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index a9526585d5..baff5fc622 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -466,6 +466,41 @@ test("v1 image edit POST routes built-in Codex references through native Respons assert.equal(captured.body.input[0].content.length, 3); }); +test("v1 image edit POST defaults Codex results to b64_json when response_format is unset (#12268)", async () => { + await seedConnection("codex", { apiKey: "codex-oauth-token" }); + + globalThis.fetch = async () => { + const event = { + type: "response.output_item.done", + item: { + type: "image_generation_call", + id: "ig_edit_default", + status: "completed", + result: "ZGVmYXVsdC1lZGl0", + }, + }; + return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + // Codex CLI's built-in image_gen never sends response_format; it expects + // the OpenAI gpt-image-* shape with the bytes in b64_json. + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + body: createCodexEditForm("make it cute"), + }) + ); + const body = (await response.json()) as ImageResponseBody & { created?: number }; + + assert.equal(response.status, 200); + assert.equal(typeof body.created, "number"); + assert.equal(body.data[0].b64_json, "ZGVmYXVsdC1lZGl0"); + assert.equal(body.data[0].url, undefined); +}); + test("v1 image edit POST rejects excessive or malformed Codex reference sets", async () => { await seedConnection("codex", { apiKey: "codex-oauth-token" }); globalThis.fetch = async () => {