fix(api): defer media body size limits to providers (#8843)

Image and video payloads vary by provider and base64 encoding adds substantial overhead. Exempt media routes from OmniRoute's global request-body cap so provider-specific validation determines whether a request is too large. Keep finite body limits for non-media routes and cover both header and streamed-body admission paths.
This commit is contained in:
NOXX - Commiter
2026-08-04 20:33:48 +03:00
committed by GitHub
parent f11d883f22
commit 45d375aa0b
4 changed files with 86 additions and 9 deletions

View File

@@ -0,0 +1 @@
- **fix(api):** Let image and video providers enforce their own request-size limits instead of rejecting media payloads at OmniRoute's 10 MB global default ([#8843](https://github.com/diegosouzapw/OmniRoute/pull/8843)) — thanks @artickc

View File

@@ -31,8 +31,15 @@ export const MAX_BODY_BYTES_FILE = 500 * 1024 * 1024;
/** Larger limit for LLM request payloads: 50 MB */
export const MAX_BODY_BYTES_LLM_API = 50 * 1024 * 1024;
/** Allows one 20 MiB image as multipart or base64 JSON plus envelope overhead. */
export const MAX_BODY_BYTES_IMAGE_EDIT = 30 * 1024 * 1024;
/**
* Media (image generate / edit / upscale / video) is not capped by OmniRoute.
* JSON + base64 inflates payloads by roughly 33%, and provider limits vary by model,
* so the provider should decide whether a media request is too large.
*/
export const MAX_BODY_BYTES_MEDIA = Number.POSITIVE_INFINITY;
/** @deprecated Use MAX_BODY_BYTES_MEDIA — kept as alias for any external imports. */
export const MAX_BODY_BYTES_IMAGE_EDIT = MAX_BODY_BYTES_MEDIA;
/** Configured limit — reads from env or falls back to 10 MB */
export const MAX_BODY_BYTES = parseRequestBodyLimitBytes(process.env.MAX_BODY_SIZE_BYTES);
@@ -43,11 +50,14 @@ const ROUTE_LIMITS: BodySizeRule[] = [
{ prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT },
{ prefix: "/api/v1/chat/completions", limit: MAX_BODY_BYTES_LLM_API },
{ prefix: "/api/v1/responses", limit: MAX_BODY_BYTES_LLM_API },
{ prefix: "/api/v1/images/edits", limit: MAX_BODY_BYTES_IMAGE_EDIT },
{ prefix: "/api/v1/images", limit: MAX_BODY_BYTES_MEDIA },
{ prefix: "/api/v1/videos", limit: MAX_BODY_BYTES_MEDIA },
{ prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO },
{ prefix: "/api/v1/files", limit: MAX_BODY_BYTES_FILE },
];
const PROVIDER_IMAGE_GENERATION_ROUTE = /^\/api\/v1\/providers\/[^/]+\/images\/generations(?:\/|$)/;
export function getConfiguredBodySizeLimitBytes(settings?: Record<string, unknown>): number {
const configuredMb = normalizeRequestBodyLimitMb(settings?.maxBodySizeMb);
return configuredMb === null ? MAX_BODY_BYTES : requestBodyLimitMbToBytes(configuredMb);
@@ -58,6 +68,7 @@ export function getConfiguredBodySizeLimitBytes(settings?: Record<string, unknow
*/
export function getBodySizeLimit(pathname: string, settings?: Record<string, unknown>): number {
const configuredLimit = getConfiguredBodySizeLimitBytes(settings);
if (PROVIDER_IMAGE_GENERATION_ROUTE.test(pathname)) return MAX_BODY_BYTES_MEDIA;
const customRule = ROUTE_LIMITS.find((rule) => pathname.startsWith(rule.prefix));
return customRule ? Math.max(customRule.limit, configuredLimit) : configuredLimit;
}

View File

@@ -5,6 +5,7 @@ import {
MAX_BODY_BYTES_AUDIO,
MAX_BODY_BYTES_FILE,
MAX_BODY_BYTES_IMAGE_EDIT,
MAX_BODY_BYTES_MEDIA,
MAX_BODY_BYTES_LLM_API,
RequestBodyTooLargeError,
readRequestBodyWithLimit,
@@ -45,7 +46,7 @@ test("body size guard keeps dedicated upload limits as lower bounds", () => {
);
assert.equal(
getBodySizeLimit("/api/v1/images/edits", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_IMAGE_EDIT
MAX_BODY_BYTES_MEDIA
);
});
@@ -171,3 +172,67 @@ test("/api/v1/files route guard allows 15 MB (10 MB+ real-world scenario)", () =
});
assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files")), null);
});
test("media routes bypass OmniRoute's configured body-size limit", () => {
assert.equal(MAX_BODY_BYTES_MEDIA, Number.POSITIVE_INFINITY);
assert.equal(MAX_BODY_BYTES_IMAGE_EDIT, MAX_BODY_BYTES_MEDIA);
assert.equal(
getBodySizeLimit("/api/v1/images/generations", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_MEDIA
);
assert.equal(
getBodySizeLimit("/api/v1/images/edits", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_MEDIA
);
assert.equal(
getBodySizeLimit("/api/v1/images/upscale", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_MEDIA
);
assert.equal(
getBodySizeLimit("/api/v1/videos/generations", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_MEDIA
);
assert.equal(
getBodySizeLimit("/api/v1/providers/openai/images/generations", { maxBodySizeMb: 10 }),
MAX_BODY_BYTES_MEDIA
);
});
test("media routes never return OmniRoute's PAYLOAD_TOO_LARGE response", () => {
for (const pathname of [
"/api/v1/images/generations",
"/api/v1/videos/generations",
"/api/v1/providers/openai/images/generations",
]) {
const request = new Request(`http://localhost${pathname}`, {
method: "POST",
headers: { "content-length": String(Number.MAX_SAFE_INTEGER) },
});
assert.equal(checkBodySize(request, getBodySizeLimit(pathname, { maxBodySizeMb: 10 })), null);
}
});
test("provider media matching does not unbound adjacent provider routes", () => {
const configuredLimit = requestBodyLimitMbToBytes(10);
for (const pathname of [
"/api/v1/providers/openai/chat/completions",
"/api/v1/providers/openai/embeddings",
"/api/v1/providers/openai/images/generations-extra",
]) {
assert.equal(getBodySizeLimit(pathname, { maxBodySizeMb: 10 }), configuredLimit);
}
});
test("image edit body reader does not enforce an OmniRoute media limit", async () => {
const request = new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { "content-length": String(Number.MAX_SAFE_INTEGER) },
body: new Uint8Array([1, 2, 3, 4]),
});
const body = await readRequestBodyWithLimit(
request,
getBodySizeLimit("/api/v1/images/edits", { maxBodySizeMb: 10 })
);
assert.deepEqual(body, new Uint8Array([1, 2, 3, 4]));
});

View File

@@ -16,7 +16,6 @@ const imageRoute = await import("../../src/app/api/v1/images/generations/route.t
const providerImageRoute =
await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts");
const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
const { MAX_BODY_BYTES_IMAGE_EDIT } = await import("../../src/shared/middleware/bodySizeGuard.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
const originalFetch = globalThis.fetch;
@@ -216,21 +215,22 @@ test("v1 image generation POST still requires prompts for text-input models", as
assert.match(body.error.message, /Prompt is required for image model: openai\/gpt-image-2/);
});
test("v1 image edit POST rejects a declared body above the image-edit admission limit", async () => {
test("v1 image edit POST defers body-size validation to the provider", async () => {
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": String(MAX_BODY_BYTES_IMAGE_EDIT + 1),
"content-length": String(Number.MAX_SAFE_INTEGER),
},
body: "{}",
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 413);
assert.match(body.error.message, /30 MiB limit/i);
assert.equal(response.status, 400);
assert.match(body.error.message, /Missing required field: prompt/i);
assert.doesNotMatch(body.error.message, /request body|payload too large/i);
});
test("v1 image edit POST enforces disabled API key policy", async () => {