diff --git a/changelog.d/fixes/8843-provider-media-body-limits.md b/changelog.d/fixes/8843-provider-media-body-limits.md new file mode 100644 index 0000000000..e90c5bab4d --- /dev/null +++ b/changelog.d/fixes/8843-provider-media-body-limits.md @@ -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 diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts index 2198319504..ff5fc33f9a 100644 --- a/src/shared/middleware/bodySizeGuard.ts +++ b/src/shared/middleware/bodySizeGuard.ts @@ -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): number { const configuredMb = normalizeRequestBodyLimitMb(settings?.maxBodySizeMb); return configuredMb === null ? MAX_BODY_BYTES : requestBodyLimitMbToBytes(configuredMb); @@ -58,6 +68,7 @@ export function getConfiguredBodySizeLimitBytes(settings?: Record): 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; } diff --git a/tests/unit/body-size-guard.test.ts b/tests/unit/body-size-guard.test.ts index 2858a9dafa..70949975da 100644 --- a/tests/unit/body-size-guard.test.ts +++ b/tests/unit/body-size-guard.test.ts @@ -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])); +}); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index df1d4de6d7..41f6078175 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -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 () => {