fix(api): build /v1/images/edits multipart as Buffer, not global FormData (#3273) (#3278)

A custom OpenAI-compatible image-edit provider received an empty `model`. In production
`globalThis.fetch` is patched with node_modules/undici's fetch, whose `FormData` class
differs from `globalThis.FormData`; passing a native FormData made undici serialize it as
the string "[object FormData]" (text/plain), dropping every field including `model`.

handleOpenAIImageEdit now assembles the multipart body as a Buffer with an explicit
boundary + Content-Type, which every fetch impl accepts verbatim.

Regression test: tests/unit/image-edits-multipart-3273.test.ts reproduces the exact prod
condition (routes through undici's fetch) — RED before (upstream got text/plain
[object FormData]), GREEN after. Existing image suites stay green (50/50).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-06 02:52:44 -03:00
committed by GitHub
parent 80c546eba9
commit e478ab23af
3 changed files with 116 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ _Development cycle in progress — entries are added as work merges into `releas
- **api/responses:** combo names without a slash (e.g. `paid-premium`, `n8n-text`) are no longer force-rewritten to `codex/<combo>` on `/v1/responses``resolveResponsesApiModel` now returns the request unchanged when the model resolves to a combo (regression from the v3.8.9 Codex WS→HTTP fallback) ([#3242](https://github.com/diegosouzapw/OmniRoute/pull/3242) — thanks @wilsonicdev; the same fix shipped via #3244, closing #3227 / #3233)
- **sse/web-tools:** web-cookie providers (e.g. `ds-web`) that wrap tool calls as `<tool_call name="...">{json}</tool_call>` are now parsed correctly — the real tool name is read from the JSON body instead of the tag attribute, and the call is no longer silently dropped when `arguments` is absent ([#3260](https://github.com/diegosouzapw/OmniRoute/issues/3260))
- **sse/groq:** non-reasoning Groq models (`llama-3.3-70b-versatile`, `llama-4-scout`) are now flagged `supportsReasoning: false`, so `reasoning_effort` / `output_config.effort` / `thinking` are stripped before dispatch instead of being forwarded and rejected with HTTP 400 — fixes the Claude Code → Groq regression of #764 ([#3258](https://github.com/diegosouzapw/OmniRoute/issues/3258))
- **api/images:** `POST /v1/images/edits` to a custom OpenAI-compatible provider no longer forwards an empty `model`. The multipart body is now built as a `Buffer` with an explicit boundary instead of a global `FormData` — the patched undici `fetch` serialized a native `FormData` as the literal string `[object FormData]` (text/plain), dropping every field including `model` ([#3273](https://github.com/diegosouzapw/OmniRoute/issues/3273))
---

View File

@@ -1027,16 +1027,40 @@ export async function handleOpenAIImageEdit({
"edits"
);
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
if (size) form.append("size", size);
if (responseFormat) form.append("response_format", responseFormat);
form.append("n", String(n || 1));
const blob = new Blob([imageBytes], { type: imageMime || "image/png" });
form.append("image", blob, "image.png");
// Build the multipart body as a Buffer with an explicit boundary instead of a global
// `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch,
// whose `FormData` class differs from `globalThis.FormData` — passing a native FormData
// makes undici serialize it as the string "[object FormData]" (text/plain), dropping every
// field (including `model`, which reaches the upstream empty). A Buffer body is accepted
// verbatim by any fetch implementation. (#3273)
const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`;
const CRLF = "\r\n";
const partBuffers: Buffer[] = [];
const appendField = (name: string, value: string) => {
partBuffers.push(
Buffer.from(
`--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}`
)
);
};
appendField("model", model);
appendField("prompt", prompt);
if (size) appendField("size", size);
if (responseFormat) appendField("response_format", responseFormat);
appendField("n", String(n || 1));
partBuffers.push(
Buffer.from(
`--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` +
`Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}`
)
);
partBuffers.push(imageBytes);
partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`));
const multipartBody = Buffer.concat(partBuffers);
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
};
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -1044,7 +1068,13 @@ export async function handleOpenAIImageEdit({
log.info("IMAGE", `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}`);
}
const result = await fetchImageEndpoint(url, headers, form as unknown as BodyInit, provider, log);
const result = await fetchImageEndpoint(
url,
headers,
multipartBody as unknown as BodyInit,
provider,
log
);
saveCallLog({
method: "POST",

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fetch as undiciFetch } from "undici";
// #3273: POST /v1/images/edits to a custom OpenAI-compatible provider forwarded an EMPTY
// model. Root cause: handleOpenAIImageEdit built a global `FormData`, but in production
// `globalThis.fetch` is patched with node_modules/undici's fetch, whose `FormData` class
// differs from `globalThis.FormData` — so undici serialized it as the string
// "[object FormData]" (text/plain), dropping every field including `model`.
// This test reproduces that exact condition by routing through undici's fetch.
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-img-3273-"));
const { handleOpenAIImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts");
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
test("#3273 /v1/images/edits forwards model as real multipart (undici-patched fetch)", async () => {
const captured = { contentType: "", body: "" };
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => {
captured.contentType = req.headers["content-type"] || "";
captured.body = Buffer.concat(chunks).toString("utf8");
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ data: [{ url: "https://example.com/out.png" }] }));
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = undiciFetch as unknown as typeof globalThis.fetch;
try {
await handleOpenAIImageEdit({
model: "gpt-image-2",
provider: "customopenai",
credentials: { apiKey: "sk-test", providerSpecificData: { baseUrl: `http://127.0.0.1:${port}` } },
prompt: "make it blue",
imageBytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
imageMime: "image/png",
});
} finally {
globalThis.fetch = originalFetch;
server.close();
}
assert.match(
captured.contentType,
/multipart\/form-data/,
`upstream must receive multipart, got "${captured.contentType}"`
);
assert.ok(captured.body.includes('name="model"'), "multipart must contain a model field");
assert.ok(captured.body.includes("gpt-image-2"), "model value must reach upstream (not empty)");
assert.ok(captured.body.includes('name="prompt"'), "prompt field must be present");
assert.ok(captured.body.includes('name="image"'), "image part must be present");
});
test.after(() => {
try {
resetDbInstance();
} catch {
/* ignore */
}
try {
fs.rmSync(process.env.DATA_DIR as string, { recursive: true, force: true });
} catch {
/* ignore */
}
});