Skip to content

Commit a192253

Browse files
committed
fix(video): bound remaining provider downloads
1 parent b022c6d commit a192253

5 files changed

Lines changed: 287 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9-
- Providers: bound generated video downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, and FAL providers, and bound generated FAL image downloads.
9+
- Providers: bound generated video downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, and Google providers, and bound generated FAL image downloads.
1010
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
1111

1212
## 2026.5.28

extensions/google/video-generation-provider.test.ts

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { writeFile } from "node:fs/promises";
2-
import path from "node:path";
31
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
42
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
53

@@ -174,6 +172,38 @@ describe("google video generation provider", () => {
174172
expect(httpOptions).not.toHaveProperty("apiVersion");
175173
});
176174

175+
it("rejects inline video bytes that exceed the configured media cap", async () => {
176+
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
177+
apiKey: "google-key",
178+
source: "env",
179+
mode: "api-key",
180+
});
181+
generateVideosMock.mockResolvedValue({
182+
done: true,
183+
response: {
184+
generatedVideos: [
185+
{
186+
video: {
187+
videoBytes: Buffer.from("too-large").toString("base64"),
188+
mimeType: "video/mp4",
189+
},
190+
},
191+
],
192+
},
193+
});
194+
195+
const provider = buildGoogleVideoGenerationProvider();
196+
await expect(
197+
provider.generateVideo({
198+
provider: "google",
199+
model: "veo-3.1-fast-generate-preview",
200+
prompt: "A tiny robot watering a windowsill garden",
201+
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
202+
durationSeconds: 3,
203+
}),
204+
).rejects.toThrow("Google generated video download exceeds 1 bytes");
205+
});
206+
177207
it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => {
178208
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
179209
apiKey: "google-key",
@@ -256,7 +286,50 @@ describe("google video generation provider", () => {
256286
expect(result.videos[0]?.mimeType).toBe("video/mp4");
257287
});
258288

259-
it("stages SDK file downloads before finalizing generated video bytes", async () => {
289+
it("rejects direct video uri downloads that exceed the configured media cap", async () => {
290+
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
291+
apiKey: "google-key",
292+
source: "env",
293+
mode: "api-key",
294+
});
295+
generateVideosMock.mockResolvedValue({
296+
done: true,
297+
response: {
298+
generatedVideos: [
299+
{
300+
video: {
301+
uri: "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media",
302+
mimeType: "video/mp4",
303+
},
304+
},
305+
],
306+
},
307+
});
308+
vi.stubGlobal(
309+
"fetch",
310+
vi.fn(
311+
async () =>
312+
new Response("too-large", {
313+
status: 200,
314+
statusText: "OK",
315+
headers: { "content-type": "video/mp4" },
316+
}),
317+
),
318+
);
319+
320+
const provider = buildGoogleVideoGenerationProvider();
321+
await expect(
322+
provider.generateVideo({
323+
provider: "google",
324+
model: "veo-3.1-fast-generate-preview",
325+
prompt: "A tiny robot watering a windowsill garden",
326+
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
327+
durationSeconds: 3,
328+
}),
329+
).rejects.toThrow("Google generated video download exceeds 1 bytes");
330+
});
331+
332+
it("downloads SDK file handles through the bounded REST media endpoint", async () => {
260333
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
261334
apiKey: "google-key",
262335
source: "env",
@@ -268,16 +341,21 @@ describe("google video generation provider", () => {
268341
generatedVideos: [
269342
{
270343
video: {
271-
name: "files/generated-video",
344+
uri: "files/generated-video",
272345
mimeType: "video/mp4",
273346
},
274347
},
275348
],
276349
},
277350
});
278-
downloadMock.mockImplementation(async ({ downloadPath }: { downloadPath: string }) => {
279-
await writeFile(downloadPath, "sdk-video");
351+
const fetchMock = vi.fn(async () => {
352+
return new Response("sdk-video", {
353+
status: 200,
354+
statusText: "OK",
355+
headers: { "content-type": "video/mp4" },
356+
});
280357
});
358+
vi.stubGlobal("fetch", fetchMock);
281359

282360
const provider = buildGoogleVideoGenerationProvider();
283361
const result = await provider.generateVideo({
@@ -288,14 +366,58 @@ describe("google video generation provider", () => {
288366
durationSeconds: 3,
289367
});
290368

291-
const [{ downloadPath }] = downloadMock.mock.calls[0] ?? [{}];
292-
const downloadBaseName = path.basename(String(downloadPath));
293-
expect(downloadBaseName).toContain("video-1.mp4");
294-
expect(downloadBaseName).toMatch(/\.part$/);
369+
expect(fetchInputUrl(fetchMock, 0)).toBe(
370+
"https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media&key=google-key",
371+
);
372+
expect(downloadMock).not.toHaveBeenCalled();
295373
expect(result.videos[0]?.buffer).toEqual(Buffer.from("sdk-video"));
296374
expect(result.videos[0]?.fileName).toBe("video-1.mp4");
297375
});
298376

377+
it("rejects SDK file-handle downloads that exceed the configured media cap", async () => {
378+
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
379+
apiKey: "google-key",
380+
source: "env",
381+
mode: "api-key",
382+
});
383+
generateVideosMock.mockResolvedValue({
384+
done: true,
385+
response: {
386+
generatedVideos: [
387+
{
388+
video: {
389+
uri: "files/generated-video",
390+
mimeType: "video/mp4",
391+
},
392+
},
393+
],
394+
},
395+
});
396+
vi.stubGlobal(
397+
"fetch",
398+
vi.fn(
399+
async () =>
400+
new Response("too-large", {
401+
status: 200,
402+
statusText: "OK",
403+
headers: { "content-type": "video/mp4" },
404+
}),
405+
),
406+
);
407+
408+
const provider = buildGoogleVideoGenerationProvider();
409+
await expect(
410+
provider.generateVideo({
411+
provider: "google",
412+
model: "veo-3.1-fast-generate-preview",
413+
prompt: "A tiny robot watering a windowsill garden",
414+
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
415+
durationSeconds: 3,
416+
}),
417+
).rejects.toThrow("Google generated video download exceeds 1 bytes");
418+
expect(downloadMock).not.toHaveBeenCalled();
419+
});
420+
299421
it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => {
300422
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
301423
apiKey: "google-key",

extensions/google/video-generation-provider.ts

Lines changed: 70 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
import { readFile } from "node:fs/promises";
2-
import path from "node:path";
31
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
42
import {
53
createProviderOperationDeadline,
64
executeProviderOperationWithRetry,
75
resolveProviderOperationTimeoutMs,
86
waitProviderOperationPollInterval,
97
} from "openclaw/plugin-sdk/provider-http";
10-
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
8+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
119
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1210
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
13-
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
1411
import type {
1512
GeneratedVideoAsset,
1613
VideoGenerationProvider,
@@ -29,6 +26,7 @@ import { createGoogleGenAI, type GoogleGenAIClient } from "./google-genai-runtim
2926
const DEFAULT_TIMEOUT_MS = 180_000;
3027
const POLL_INTERVAL_MS = 10_000;
3128
const MAX_POLL_ATTEMPTS = 120;
29+
const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024;
3230
const GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE =
3331
"Google video generation response missing generated videos";
3432

@@ -37,6 +35,20 @@ function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): strin
3735
return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined;
3836
}
3937

38+
function resolveGeneratedVideoMaxBytes(req: VideoGenerationRequest): number {
39+
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
40+
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
41+
return Math.floor(configured * 1024 * 1024);
42+
}
43+
return DEFAULT_GENERATED_VIDEO_MAX_BYTES;
44+
}
45+
46+
function assertGeneratedVideoBufferWithinLimit(buffer: Buffer, maxBytes: number): void {
47+
if (buffer.length > maxBytes) {
48+
throw new Error(`Google generated video download exceeds ${maxBytes} bytes`);
49+
}
50+
}
51+
4052
function resolveGoogleVideoRestBaseUrl(configuredBaseUrl?: string): string {
4153
return `${configuredBaseUrl ?? "https://generativelanguage.googleapis.com"}/v1beta`;
4254
}
@@ -148,42 +160,6 @@ function resolveInputVideo(req: VideoGenerationRequest) {
148160
};
149161
}
150162

151-
async function downloadGeneratedVideo(params: {
152-
client: GoogleGenAIClient;
153-
file: unknown;
154-
index: number;
155-
}): Promise<GeneratedVideoAsset> {
156-
return await withTempWorkspace(
157-
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" },
158-
async ({ dir: tempDir }) => {
159-
const fileName = `video-${params.index + 1}.mp4`;
160-
const downloadPath = path.join(tempDir, fileName);
161-
await writeExternalFileWithinRoot({
162-
rootDir: tempDir,
163-
path: fileName,
164-
write: async (downloadPath) => {
165-
await executeProviderOperationWithRetry({
166-
provider: "google",
167-
stage: "download",
168-
operation: async () => {
169-
await params.client.files.download({
170-
file: params.file as never,
171-
downloadPath,
172-
});
173-
},
174-
});
175-
},
176-
});
177-
const buffer = await readFile(downloadPath);
178-
return {
179-
buffer,
180-
mimeType: "video/mp4",
181-
fileName: `video-${params.index + 1}.mp4`,
182-
};
183-
},
184-
);
185-
}
186-
187163
function resolveGoogleGeneratedVideoDownloadUrl(params: {
188164
uri: string | undefined;
189165
apiKey: string;
@@ -222,12 +198,31 @@ function resolveGoogleGeneratedVideoDownloadUrl(params: {
222198
return url.toString();
223199
}
224200

201+
function resolveGoogleGeneratedVideoFileDownloadUrl(params: {
202+
file: unknown;
203+
apiKey: string;
204+
configuredBaseUrl?: string;
205+
}): string | undefined {
206+
const resource = params.file as { name?: unknown; uri?: unknown } | undefined;
207+
const name = normalizeOptionalString(resource?.name) ?? normalizeOptionalString(resource?.uri);
208+
if (!name || !/^files\/[^/?#]+$/u.test(name)) {
209+
return undefined;
210+
}
211+
const baseUrl = resolveGoogleVideoRestBaseUrl(params.configuredBaseUrl);
212+
const url = new URL(`${baseUrl}/${name}:download`);
213+
url.searchParams.set("alt", "media");
214+
url.searchParams.set("key", params.apiKey);
215+
return url.toString();
216+
}
217+
225218
async function downloadGeneratedVideoFromUri(params: {
226219
uri: string | undefined;
227220
apiKey: string;
228221
configuredBaseUrl?: string;
229222
mimeType?: string;
230223
index: number;
224+
maxBytes: number;
225+
timeoutMs: number;
231226
}): Promise<GeneratedVideoAsset | undefined> {
232227
const downloadUrl = resolveGoogleGeneratedVideoDownloadUrl({
233228
uri: params.uri,
@@ -243,14 +238,21 @@ async function downloadGeneratedVideoFromUri(params: {
243238
operation: async () => {
244239
const { response, release } = await fetchWithSsrFGuard({
245240
url: downloadUrl,
241+
timeoutMs: params.timeoutMs,
246242
});
247243
try {
248244
if (!response.ok) {
249245
throw new Error(
250246
`Failed to download Google generated video: ${response.status} ${response.statusText}`,
251247
);
252248
}
253-
const buffer = Buffer.from(await response.arrayBuffer());
249+
const buffer = await readResponseWithLimit(response, params.maxBytes, {
250+
chunkTimeoutMs: params.timeoutMs,
251+
onOverflow: ({ maxBytes }) =>
252+
new Error(`Google generated video download exceeds ${maxBytes} bytes`),
253+
onIdleTimeout: ({ chunkTimeoutMs }) =>
254+
new Error(`Google generated video download stalled after ${chunkTimeoutMs}ms`),
255+
});
254256
return {
255257
buffer,
256258
mimeType:
@@ -545,14 +547,17 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
545547
if (generatedVideos.length === 0) {
546548
throw new Error(GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE);
547549
}
550+
const maxVideoBytes = resolveGeneratedVideoMaxBytes(req);
548551
const videos = await Promise.all(
549552
generatedVideos.map(async (entry, index) => {
550553
const inline = entry.video as
551554
| { videoBytes?: string; uri?: string; mimeType?: string }
552555
| undefined;
553556
if (inline?.videoBytes) {
557+
const buffer = Buffer.from(inline.videoBytes, "base64");
558+
assertGeneratedVideoBufferWithinLimit(buffer, maxVideoBytes);
554559
return {
555-
buffer: Buffer.from(inline.videoBytes, "base64"),
560+
buffer,
556561
mimeType: normalizeOptionalString(inline.mimeType) || "video/mp4",
557562
fileName: `video-${index + 1}.mp4`,
558563
};
@@ -563,18 +568,38 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
563568
configuredBaseUrl,
564569
mimeType: inline?.mimeType,
565570
index,
571+
maxBytes: maxVideoBytes,
572+
timeoutMs: resolveProviderOperationTimeoutMs({
573+
deadline,
574+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
575+
}),
566576
});
567577
if (directDownload) {
568578
return directDownload;
569579
}
570580
if (!inline) {
571581
throw new Error("Google generated video missing file handle");
572582
}
573-
return await downloadGeneratedVideo({
574-
client,
575-
file: inline,
583+
const fileDownload = await downloadGeneratedVideoFromUri({
584+
uri: resolveGoogleGeneratedVideoFileDownloadUrl({
585+
file: inline,
586+
apiKey,
587+
configuredBaseUrl,
588+
}),
589+
apiKey,
590+
configuredBaseUrl,
591+
mimeType: inline.mimeType,
576592
index,
593+
maxBytes: maxVideoBytes,
594+
timeoutMs: resolveProviderOperationTimeoutMs({
595+
deadline,
596+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
597+
}),
577598
});
599+
if (!fileDownload) {
600+
throw new Error("Google generated video missing bounded download URL");
601+
}
602+
return fileDownload;
578603
}),
579604
);
580605
return {

0 commit comments

Comments
 (0)