From 7d5dd8aad26d77bab4d7676aa9ae3bbeb25fd1b5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 14:09:53 +0200 Subject: [PATCH] fix(fal): bound generated media downloads --- CHANGELOG.md | 2 +- .../fal/image-generation-provider.test.ts | 39 +++++++++++++++++++ extensions/fal/image-generation-provider.ts | 25 ++++++++++-- .../fal/video-generation-provider.test.ts | 36 +++++++++++++++-- extensions/fal/video-generation-provider.ts | 38 ++++++++++++++++-- 5 files changed, 128 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29720aca329b..f22cb7da3d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Providers: bound generated video downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, and DashScope-compatible providers. +- Providers: bound generated video downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, and FAL providers, and bound generated FAL image downloads. - Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot. ## 2026.5.28 diff --git a/extensions/fal/image-generation-provider.test.ts b/extensions/fal/image-generation-provider.test.ts index 6af094d911e5..4b54df7cdbe9 100644 --- a/extensions/fal/image-generation-provider.test.ts +++ b/extensions/fal/image-generation-provider.test.ts @@ -116,6 +116,45 @@ describe("fal image-generation provider", () => { }); }); + it("rejects generated image downloads that exceed the configured media cap", async () => { + vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + images: [{ url: "https://v3.fal.media/files/example/generated.png" }], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + release: vi.fn(async () => {}), + }) + .mockResolvedValueOnce({ + response: new Response(Buffer.from("too-large"), { + status: 200, + headers: { "content-type": "image/png" }, + }), + release: vi.fn(async () => {}), + }); + + const provider = buildFalImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "fal", + model: "fal-ai/flux/dev", + prompt: "draw a cat", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }), + ).rejects.toThrow("fal generated image download exceeds 1 bytes"); + }); + it("wraps wrong-shape successful fal image responses", async () => { vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "fal-test-key", diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index d3ea2c71dfca..82b35afc6c72 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -12,6 +12,7 @@ import { assertOkOrThrowHttpError, assertOkOrThrowProviderError, } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { buildHostnameAllowlistPolicyFromSuffixAllowlist, fetchWithSsrFGuard, @@ -89,6 +90,7 @@ const NANO_BANANA_SUPPORTED_ASPECT_RATIOS = [ const KREA_CREATIVITY_LEVELS = ["raw", "low", "medium", "high"] as const; const FAL_IMAGE_MALFORMED_RESPONSE = "fal image generation response malformed"; +const DEFAULT_GENERATED_IMAGE_MAX_BYTES = 6 * 1024 * 1024; type FalImageSize = string | { width: number; height: number }; type FalImageModelSchema = { @@ -472,9 +474,20 @@ function formatFalReferenceLimitError( return `${schema.referenceLimitLabel} supports at most ${limit} ${noun} (requested ${inputImageCount})`; } +function resolveGeneratedImageMaxBytes(req: { + cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; +}): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * 1024 * 1024); + } + return DEFAULT_GENERATED_IMAGE_MAX_BYTES; +} + async function fetchImageBuffer( url: string, networkPolicy?: FalNetworkPolicy, + maxBytes = DEFAULT_GENERATED_IMAGE_MAX_BYTES, ): Promise<{ buffer: Buffer; mimeType: string }> { const downloadPolicy = (() => { const trustedSuffix = networkPolicy?.trustedDownloadHostSuffix; @@ -497,8 +510,13 @@ async function fetchImageBuffer( try { await assertOkOrThrowProviderError(response, "fal image download failed"); const mimeType = response.headers.get("content-type")?.trim() || "image/png"; - const arrayBuffer = await response.arrayBuffer(); - return { buffer: Buffer.from(arrayBuffer), mimeType }; + return { + buffer: await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes }) => + new Error(`fal generated image download exceeds ${maxBytes} bytes`), + }), + mimeType, + }; } finally { await release(); } @@ -580,6 +598,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = await resolveFalHttpRequestConfig({ req, capability: "image" }); const networkPolicy = resolveFalNetworkPolicy({ baseUrl, allowPrivateNetwork }); + const maxImageBytes = resolveGeneratedImageMaxBytes(req); const requestBody: Record = { prompt: req.prompt, ...(schema.supportsCount ? { num_images: req.count ?? 1 } : {}), @@ -633,7 +652,7 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { if (!url) { throw new Error(FAL_IMAGE_MALFORMED_RESPONSE); } - const downloaded = await fetchImageBuffer(url, networkPolicy); + const downloaded = await fetchImageBuffer(url, networkPolicy, maxImageBytes); imageIndex += 1; images.push({ buffer: downloaded.buffer, diff --git a/extensions/fal/video-generation-provider.test.ts b/extensions/fal/video-generation-provider.test.ts index e4c9224e0c07..c2d9259041d4 100644 --- a/extensions/fal/video-generation-provider.test.ts +++ b/extensions/fal/video-generation-provider.test.ts @@ -44,10 +44,10 @@ describe("fal video generation provider", () => { function releasedVideo(params: { contentType: string; bytes: string }) { return { - response: { - headers: new Headers({ "content-type": params.contentType }), - arrayBuffer: async () => Buffer.from(params.bytes), - }, + response: new Response(Buffer.from(params.bytes), { + status: 200, + headers: { "content-type": params.contentType }, + }), release: vi.fn(async () => {}), }; } @@ -170,6 +170,34 @@ describe("fal video generation provider", () => { }); }); + it("returns URL-only videos when generated video downloads exceed the configured media cap", async () => { + mockFalProviderRuntime(); + mockCompletedFalVideoJob({ + requestId: "req-123", + statusUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status", + responseUrl: "https://queue.fal.run/fal-ai/minimax/requests/req-123", + videoUrl: "https://fal.run/files/video.mp4", + bytes: "too-large", + contentType: "video/mp4", + }); + + const provider = buildFalVideoGenerationProvider(); + const result = await provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "A spaceship emerges from the clouds", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + }); + + expect(result.videos).toEqual([ + { + url: "https://fal.run/files/video.mp4", + mimeType: "video/mp4", + fileName: "video-1.mp4", + }, + ]); + }); + it("wraps malformed successful fal submit responses", async () => { mockFalProviderRuntime(); fetchGuardMock.mockResolvedValueOnce(releasedJson([])); diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index ee904230cc7f..c6de2a9470f8 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -1,6 +1,7 @@ import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { assertOkOrThrowHttpError } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy, @@ -51,6 +52,7 @@ const SEEDANCE_REFERENCE_MAX_AUDIOS_BY_MODEL = Object.fromEntries( ); const DEFAULT_HTTP_TIMEOUT_MS = 30_000; const DEFAULT_OPERATION_TIMEOUT_MS = 1_200_000; +const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024; const POLL_INTERVAL_MS = 5_000; const FAL_VIDEO_MALFORMED_RESPONSE = "fal video generation response malformed"; const FAL_VIDEO_PENDING_STATUSES = new Set([ @@ -173,9 +175,18 @@ function extractFalVideoEntry(payload: FalVideoResponse) { return payload.videos?.find((entry) => normalizeOptionalString(entry.url)); } +function resolveGeneratedVideoMaxBytes(req: VideoGenerationRequest): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * 1024 * 1024); + } + return DEFAULT_GENERATED_VIDEO_MAX_BYTES; +} + async function downloadFalVideo( url: string, policy: SsrFPolicy | undefined, + maxBytes: number, ): Promise { const { response, release } = await falFetchGuard({ url, @@ -186,12 +197,31 @@ async function downloadFalVideo( try { await assertOkOrThrowHttpError(response, "fal generated video download failed"); const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; - const arrayBuffer = await response.arrayBuffer(); + const fileName = `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`; + let exceededMaxBytes = false; + let buffer: Buffer; + try { + buffer = await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes }) => { + exceededMaxBytes = true; + return new Error(`fal generated video download exceeds ${maxBytes} bytes`); + }, + }); + } catch (error) { + if (exceededMaxBytes) { + return { + url, + mimeType, + fileName, + }; + } + throw error; + } return { url, - buffer: Buffer.from(arrayBuffer), + buffer, mimeType, - fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`, + fileName, }; } finally { await release(); @@ -608,7 +638,7 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { if (!url) { throw new Error("fal video generation response missing output URL"); } - const video = await downloadFalVideo(url, policy); + const video = await downloadFalVideo(url, policy, resolveGeneratedVideoMaxBytes(req)); return { videos: [video], model,