diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a636bed42..5fc2cae45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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 `{json}` 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)) --- diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index b438fee1c4..3b2da49eaa 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -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 = {}; + const headers: Record = { + "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", diff --git a/tests/unit/image-edits-multipart-3273.test.ts b/tests/unit/image-edits-multipart-3273.test.ts new file mode 100644 index 0000000000..d7e3e390dd --- /dev/null +++ b/tests/unit/image-edits-multipart-3273.test.ts @@ -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((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 */ + } +});