mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(fal): bound generated media downloads
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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([]));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user