mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
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:
1
changelog.d/fixes/8843-provider-media-body-limits.md
Normal file
1
changelog.d/fixes/8843-provider-media-body-limits.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user