mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
80c546eba9
commit
e478ab23af
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
75
tests/unit/image-edits-multipart-3273.test.ts
Normal file
75
tests/unit/image-edits-multipart-3273.test.ts
Normal 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 */
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user