fix(fal): bound generated media downloads

This commit is contained in:
Vincent Koc
2026-05-29 14:09:53 +02:00
parent 476d0a2c4b
commit 7d5dd8aad2
5 changed files with 128 additions and 12 deletions
+1 -1
View File
@@ -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
@@ -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",
+22 -3
View File
@@ -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<string, unknown> = {
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,
@@ -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([]));
+34 -4
View File
@@ -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<GeneratedVideoAsset> {
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,