Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

---

Expand Down
52 changes: 41 additions & 11 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1027,24 +1027,54 @@ 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");

const headers: Record<string, string> = {};
// 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> = {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
};
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers["Authorization"] = `Bearer ${token}`;

if (log) {
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",
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/image-edits-multipart-3273.test.ts
Original file line number Diff line number Diff line change
@@ -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 */
}
});