mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
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:
1
changelog.d/fixes/12362-image-gen-response-wrapper.md
Normal file
1
changelog.d/fixes/12362-image-gen-response-wrapper.md
Normal 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))
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user