fix(api): keep the images wrapper on combo routes and default Codex to b64_json (#12362)

POST /v1/images/generations through a combo returned a bare array instead of the OpenAI {created, data} payload: executeImageCombo() unwrapped one level too many, and the n used for cost calculation read the same double-nested shape, so it was always 0. The combo path now returns the handler payload unchanged, matching the direct-model path.

Second half: Codex image results emitted a data: URI in url whenever response_format was not b64_json, but OpenAI returns b64_json for the gpt-image-* family — clients that omit the field, Codex CLI's built-in image_gen among them, could decode neither shape. Codex now defaults to b64_json; an explicit response_format: "url" keeps its previous behaviour. Both land together because fixing one leaves Codex CLI failing at the other.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:14:09 +02:00
committed by GitHub
parent 3a5641839e
commit 8d16a50df5
6 changed files with 149 additions and 31 deletions

View File

@@ -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))

View File

@@ -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}`,

View File

@@ -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" },
});
}
}

View File

@@ -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<Record<string, unknown>>): 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;
}
});

View File

@@ -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 {

View File

@@ -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 () => {