fix(video): bound remaining provider downloads

This commit is contained in:
Vincent Koc
2026-05-29 14:47:05 +02:00
parent b022c6d770
commit a19225343b
5 changed files with 287 additions and 64 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<GeneratedVideoAsset> {
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<GeneratedVideoAsset | undefined> {
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 {

View File

@@ -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(

View File

@@ -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<GeneratedVideoAsset> {
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 {