From a19225343b459fc16842275fae48667fd207c40f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 14:47:05 +0200 Subject: [PATCH] fix(video): bound remaining provider downloads --- CHANGELOG.md | 2 +- .../google/video-generation-provider.test.ts | 142 ++++++++++++++++-- .../google/video-generation-provider.ts | 115 ++++++++------ .../video-generation-provider.test.ts | 38 ++++- .../openrouter/video-generation-provider.ts | 54 ++++++- 5 files changed, 287 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22cb7da3d2..6bab4ec4205 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, DashScope-compatible, and FAL providers, and bound generated FAL image downloads. +- Providers: bound generated video downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, and Google 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/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 87e8e8c8498..ac412c44d90 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -1,5 +1,3 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -174,6 +172,38 @@ describe("google video generation provider", () => { expect(httpOptions).not.toHaveProperty("apiVersion"); }); + it("rejects inline video bytes that exceed the configured media cap", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { + video: { + videoBytes: Buffer.from("too-large").toString("base64"), + mimeType: "video/mp4", + }, + }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + durationSeconds: 3, + }), + ).rejects.toThrow("Google generated video download exceeds 1 bytes"); + }); + it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", @@ -256,7 +286,7 @@ describe("google video generation provider", () => { expect(result.videos[0]?.mimeType).toBe("video/mp4"); }); - it("stages SDK file downloads before finalizing generated video bytes", async () => { + it("rejects direct video uri downloads that exceed the configured media cap", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", source: "env", @@ -268,16 +298,64 @@ describe("google video generation provider", () => { generatedVideos: [ { video: { - name: "files/generated-video", + uri: "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media", mimeType: "video/mp4", }, }, ], }, }); - downloadMock.mockImplementation(async ({ downloadPath }: { downloadPath: string }) => { - await writeFile(downloadPath, "sdk-video"); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("too-large", { + status: 200, + statusText: "OK", + headers: { "content-type": "video/mp4" }, + }), + ), + ); + + const provider = buildGoogleVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + durationSeconds: 3, + }), + ).rejects.toThrow("Google generated video download exceeds 1 bytes"); + }); + + it("downloads SDK file handles through the bounded REST media endpoint", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { + video: { + uri: "files/generated-video", + mimeType: "video/mp4", + }, + }, + ], + }, + }); + const fetchMock = vi.fn(async () => { + return new Response("sdk-video", { + status: 200, + statusText: "OK", + headers: { "content-type": "video/mp4" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); const provider = buildGoogleVideoGenerationProvider(); const result = await provider.generateVideo({ @@ -288,14 +366,58 @@ describe("google video generation provider", () => { durationSeconds: 3, }); - const [{ downloadPath }] = downloadMock.mock.calls[0] ?? [{}]; - const downloadBaseName = path.basename(String(downloadPath)); - expect(downloadBaseName).toContain("video-1.mp4"); - expect(downloadBaseName).toMatch(/\.part$/); + expect(fetchInputUrl(fetchMock, 0)).toBe( + "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media&key=google-key", + ); + expect(downloadMock).not.toHaveBeenCalled(); expect(result.videos[0]?.buffer).toEqual(Buffer.from("sdk-video")); expect(result.videos[0]?.fileName).toBe("video-1.mp4"); }); + it("rejects SDK file-handle downloads that exceed the configured media cap", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { + video: { + uri: "files/generated-video", + mimeType: "video/mp4", + }, + }, + ], + }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("too-large", { + status: 200, + statusText: "OK", + headers: { "content-type": "video/mp4" }, + }), + ), + ); + + const provider = buildGoogleVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } }, + durationSeconds: 3, + }), + ).rejects.toThrow("Google generated video download exceeds 1 bytes"); + expect(downloadMock).not.toHaveBeenCalled(); + }); + it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index 57fb2767e4c..d11f62b8533 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -1,5 +1,3 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { createProviderOperationDeadline, @@ -7,10 +5,9 @@ import { resolveProviderOperationTimeoutMs, waitProviderOperationPollInterval, } from "openclaw/plugin-sdk/provider-http"; -import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -29,6 +26,7 @@ import { createGoogleGenAI, type GoogleGenAIClient } from "./google-genai-runtim const DEFAULT_TIMEOUT_MS = 180_000; const POLL_INTERVAL_MS = 10_000; const MAX_POLL_ATTEMPTS = 120; +const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024; const GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE = "Google video generation response missing generated videos"; @@ -37,6 +35,20 @@ function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): strin return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined; } +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; +} + +function assertGeneratedVideoBufferWithinLimit(buffer: Buffer, maxBytes: number): void { + if (buffer.length > maxBytes) { + throw new Error(`Google generated video download exceeds ${maxBytes} bytes`); + } +} + function resolveGoogleVideoRestBaseUrl(configuredBaseUrl?: string): string { return `${configuredBaseUrl ?? "https://generativelanguage.googleapis.com"}/v1beta`; } @@ -148,42 +160,6 @@ function resolveInputVideo(req: VideoGenerationRequest) { }; } -async function downloadGeneratedVideo(params: { - client: GoogleGenAIClient; - file: unknown; - index: number; -}): Promise { - return await withTempWorkspace( - { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" }, - async ({ dir: tempDir }) => { - const fileName = `video-${params.index + 1}.mp4`; - const downloadPath = path.join(tempDir, fileName); - await writeExternalFileWithinRoot({ - rootDir: tempDir, - path: fileName, - write: async (downloadPath) => { - await executeProviderOperationWithRetry({ - provider: "google", - stage: "download", - operation: async () => { - await params.client.files.download({ - file: params.file as never, - downloadPath, - }); - }, - }); - }, - }); - const buffer = await readFile(downloadPath); - return { - buffer, - mimeType: "video/mp4", - fileName: `video-${params.index + 1}.mp4`, - }; - }, - ); -} - function resolveGoogleGeneratedVideoDownloadUrl(params: { uri: string | undefined; apiKey: string; @@ -222,12 +198,31 @@ function resolveGoogleGeneratedVideoDownloadUrl(params: { return url.toString(); } +function resolveGoogleGeneratedVideoFileDownloadUrl(params: { + file: unknown; + apiKey: string; + configuredBaseUrl?: string; +}): string | undefined { + const resource = params.file as { name?: unknown; uri?: unknown } | undefined; + const name = normalizeOptionalString(resource?.name) ?? normalizeOptionalString(resource?.uri); + if (!name || !/^files\/[^/?#]+$/u.test(name)) { + return undefined; + } + const baseUrl = resolveGoogleVideoRestBaseUrl(params.configuredBaseUrl); + const url = new URL(`${baseUrl}/${name}:download`); + url.searchParams.set("alt", "media"); + url.searchParams.set("key", params.apiKey); + return url.toString(); +} + async function downloadGeneratedVideoFromUri(params: { uri: string | undefined; apiKey: string; configuredBaseUrl?: string; mimeType?: string; index: number; + maxBytes: number; + timeoutMs: number; }): Promise { const downloadUrl = resolveGoogleGeneratedVideoDownloadUrl({ uri: params.uri, @@ -243,6 +238,7 @@ async function downloadGeneratedVideoFromUri(params: { operation: async () => { const { response, release } = await fetchWithSsrFGuard({ url: downloadUrl, + timeoutMs: params.timeoutMs, }); try { if (!response.ok) { @@ -250,7 +246,13 @@ async function downloadGeneratedVideoFromUri(params: { `Failed to download Google generated video: ${response.status} ${response.statusText}`, ); } - const buffer = Buffer.from(await response.arrayBuffer()); + const buffer = await readResponseWithLimit(response, params.maxBytes, { + chunkTimeoutMs: params.timeoutMs, + onOverflow: ({ maxBytes }) => + new Error(`Google generated video download exceeds ${maxBytes} bytes`), + onIdleTimeout: ({ chunkTimeoutMs }) => + new Error(`Google generated video download stalled after ${chunkTimeoutMs}ms`), + }); return { buffer, mimeType: @@ -545,14 +547,17 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { if (generatedVideos.length === 0) { throw new Error(GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE); } + const maxVideoBytes = resolveGeneratedVideoMaxBytes(req); const videos = await Promise.all( generatedVideos.map(async (entry, index) => { const inline = entry.video as | { videoBytes?: string; uri?: string; mimeType?: string } | undefined; if (inline?.videoBytes) { + const buffer = Buffer.from(inline.videoBytes, "base64"); + assertGeneratedVideoBufferWithinLimit(buffer, maxVideoBytes); return { - buffer: Buffer.from(inline.videoBytes, "base64"), + buffer, mimeType: normalizeOptionalString(inline.mimeType) || "video/mp4", fileName: `video-${index + 1}.mp4`, }; @@ -563,6 +568,11 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { configuredBaseUrl, mimeType: inline?.mimeType, index, + maxBytes: maxVideoBytes, + timeoutMs: resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), }); if (directDownload) { return directDownload; @@ -570,11 +580,26 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { if (!inline) { throw new Error("Google generated video missing file handle"); } - return await downloadGeneratedVideo({ - client, - file: inline, + const fileDownload = await downloadGeneratedVideoFromUri({ + uri: resolveGoogleGeneratedVideoFileDownloadUrl({ + file: inline, + apiKey, + configuredBaseUrl, + }), + apiKey, + configuredBaseUrl, + mimeType: inline.mimeType, index, + maxBytes: maxVideoBytes, + timeoutMs: resolveProviderOperationTimeoutMs({ + deadline, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + }), }); + if (!fileDownload) { + throw new Error("Google generated video missing bounded download URL"); + } + return fileDownload; }), ); return { diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index a5b86cabd0a..6bfab462cb6 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -62,10 +62,10 @@ function releasedJson(value: unknown) { 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 () => {}), }; } @@ -553,6 +553,36 @@ describe("openrouter video generation provider", () => { }); }); + it("returns unsigned URL-only videos when downloads exceed the configured media cap", async () => { + postJsonRequestMock.mockResolvedValue( + releasedJson({ + id: "job-123", + polling_url: "/api/v1/videos/job-123", + status: "completed", + unsigned_urls: ["https://cdn.openrouter.test/video.mp4"], + }), + ); + fetchWithTimeoutGuardedMock.mockResolvedValueOnce( + releasedVideo({ contentType: "video/mp4", bytes: "too-large" }), + ); + + const provider = buildOpenRouterVideoGenerationProvider(); + const result = await provider.generateVideo({ + provider: "openrouter", + model: "google/veo-3.1", + prompt: "A glass cube reflects a neon skyline", + cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } as never, + }); + + expect(result.videos).toEqual([ + { + url: "https://cdn.openrouter.test/video.mp4", + mimeType: "video/mp4", + fileName: "video-1.mp4", + }, + ]); + }); + it("rejects malformed numeric seed values before submitting video jobs", async () => { const provider = buildOpenRouterVideoGenerationProvider(); await expect( diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index 14cf1956a56..5a0f619d37a 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -10,6 +10,7 @@ import { sanitizeConfiguredModelProviderRequest, waitProviderOperationPollInterval, } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeneratedVideoAsset, @@ -32,6 +33,7 @@ const DEFAULT_TIMEOUT_MS = 600_000; const DEFAULT_HTTP_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 5_000; const MAX_POLL_ATTEMPTS = 120; +const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024; const SUPPORTED_ASPECT_RATIOS = ["16:9", "9:16"] as const; const OPENROUTER_VIDEO_MALFORMED_RESPONSE = "OpenRouter video generation response malformed"; const SUPPORTED_DURATION_SECONDS = [4, 6, 8] as const; @@ -349,13 +351,36 @@ function resolveOpenRouterContentUrl(params: { baseUrl: string; jobId: string }) ); } +function resolveDeliverableOpenRouterVideoUrl(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + try { + const url = new URL(normalized); + return url.protocol === "https:" || url.protocol === "http:" ? normalized : undefined; + } catch { + return undefined; + } +} + +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 downloadOpenRouterVideo(params: { url: string; + deliveryUrl?: string; baseUrl: string; headers: Headers; timeoutMs: number; allowPrivateNetwork: boolean; dispatcherPolicy: OpenRouterVideoDispatcherPolicy; + maxBytes: number; }): Promise { const { response, release } = await fetchOpenRouterVideoGet({ ...params, @@ -364,11 +389,30 @@ async function downloadOpenRouterVideo(params: { try { await assertOkOrThrowHttpError(response, "OpenRouter generated video download failed"); const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; - const buffer = Buffer.from(await response.arrayBuffer()); + const fileName = `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`; + let exceededMaxBytes = false; + let buffer: Buffer; + try { + buffer = await readResponseWithLimit(response, params.maxBytes, { + onOverflow: ({ maxBytes }) => { + exceededMaxBytes = true; + return new Error(`OpenRouter generated video download exceeds ${maxBytes} bytes`); + }, + }); + } catch (error) { + if (exceededMaxBytes && params.deliveryUrl) { + return { + url: params.deliveryUrl, + mimeType, + fileName, + }; + } + throw error; + } return { buffer, mimeType, - fileName: `video-1.${extensionForMime(mimeType)?.slice(1) ?? "mp4"}`, + fileName, }; } finally { await release(); @@ -504,11 +548,12 @@ export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvide dispatcherPolicy, }); const completedJobId = normalizeOptionalString(completed.id) ?? jobId; + const unsignedUrl = completed.unsigned_urls?.find((url) => normalizeOptionalString(url)); const videoUrl = - completed.unsigned_urls?.find((url) => normalizeOptionalString(url)) ?? - resolveOpenRouterContentUrl({ baseUrl, jobId: completedJobId }); + unsignedUrl ?? resolveOpenRouterContentUrl({ baseUrl, jobId: completedJobId }); const video = await downloadOpenRouterVideo({ url: videoUrl, + deliveryUrl: resolveDeliverableOpenRouterVideoUrl(unsignedUrl), baseUrl, headers, timeoutMs: resolveProviderOperationTimeoutMs({ @@ -517,6 +562,7 @@ export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvide }), allowPrivateNetwork, dispatcherPolicy, + maxBytes: resolveGeneratedVideoMaxBytes(req), }); return {