fix: bound live video generation smoke

This commit is contained in:
Peter Steinberger
2026-04-14 14:59:01 +01:00
parent 4015138df9
commit a88c6f0fe7
23 changed files with 749 additions and 229 deletions

View File

@@ -90,6 +90,15 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
now fails the candidate update tarball when npm reports an oversized
`unpackedSize`, so release-time e2e cannot miss pack bloat that would risk
low-memory install/startup failures.
- Use `pnpm test:live:media video` for bounded video-provider smoke when video
generation is in release scope. The default video smoke skips `fal`, runs one
text-to-video attempt per provider with a one-second lobster prompt, and caps
each provider operation with `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS`
(`180000` by default).
- Run `pnpm test:live:media video --video-providers fal` only when FAL-specific
proof is required. Its queue latency can dominate release time.
- Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` only when intentionally
validating the slower image-to-video and video-to-video transform lanes.
## Check all relevant release builds

View File

@@ -11,6 +11,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.
## 2026.4.14
### Changes

View File

@@ -1,2 +1,2 @@
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl
6eaa32f7af20ef87952510a09f400f3c48acf25db408f067eea4ea9b86d70514 plugin-sdk-api-baseline.json
d4276644b0bd99bd37a54d1498ec3487375d20fde6906fb0bfa146d91f352d1d plugin-sdk-api-baseline.jsonl

View File

@@ -781,11 +781,13 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Harness: `pnpm test:live:media video`
- Scope:
- Exercises the shared bundled video-generation provider path
- Defaults to the release-safe smoke path: non-FAL providers, one text-to-video request per provider, one-second lobster prompt, and a per-provider operation cap from `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS` (`180000` by default)
- Skips FAL by default because provider-side queue latency can dominate release time; pass `--video-providers fal` or `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="fal"` to run it explicitly
- Loads provider env vars from your login shell (`~/.profile`) before probing
- Uses live/env API keys ahead of stored auth profiles by default, so stale test keys in `auth-profiles.json` do not mask real shell credentials
- Skips providers with no usable auth/profile/model
- Runs both declared runtime modes when available:
- `generate` with prompt-only input
- Runs only `generate` by default
- Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` to also run declared transform modes when available:
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
@@ -802,6 +804,8 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Optional narrowing:
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
- `OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS=""` to include every provider in the default sweep, including FAL
- `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS=60000` to reduce each provider operation cap for an aggressive smoke run
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides

View File

@@ -316,10 +316,23 @@ pnpm test:live:media video
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs the
declared modes it can exercise safely with local media:
live/env API keys ahead of stored auth profiles by default, and runs a
release-safe smoke by default:
- `generate` for every non-FAL provider in the sweep
- one-second lobster prompt
- per-provider operation cap from `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS`
(`180000` by default)
FAL is opt-in because provider-side queue latency can dominate release time:
```bash
pnpm test:live:media video --video-providers fal
```
Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` to also run declared transform
modes the shared sweep can exercise safely with local media:
- `generate` for every provider in the sweep
- `imageToVideo` when `capabilities.imageToVideo.enabled`
- `videoToVideo` when `capabilities.videoToVideo.enabled` and the provider/model
accepts buffer-backed local video input in the shared sweep

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -73,6 +76,10 @@ async function pollBytePlusTask(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<BytePlusTaskResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `BytePlus video generation task ${params.taskId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/contents/generations/tasks/${params.taskId}`,
@@ -80,7 +87,7 @@ async function pollBytePlusTask(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "BytePlus video status request failed");
@@ -96,7 +103,7 @@ async function pollBytePlusTask(params: {
case "queued":
case "running":
default:
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;
}
}
@@ -183,6 +190,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "BytePlus video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveBytePlusVideoBaseUrl(req),
@@ -261,7 +272,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
url: `${baseUrl}/contents/generations/tasks`,
headers,
body,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -276,7 +290,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
const completed = await pollBytePlusTask({
taskId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
@@ -286,7 +303,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
}
const video = await downloadBytePlusVideo({
url: videoUrl,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
});
return {

View File

@@ -3,6 +3,11 @@ import path from "node:path";
import { GoogleGenAI } from "@google/genai";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
createProviderOperationDeadline,
resolveProviderOperationTimeoutMs,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -225,11 +230,18 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
const configuredBaseUrl = resolveConfiguredGoogleVideoBaseUrl(req);
const durationSeconds = resolveDurationSeconds(req.durationSeconds);
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "Google video generation",
});
const client = new GoogleGenAI({
apiKey: auth.apiKey,
httpOptions: {
...(configuredBaseUrl ? { baseUrl: configuredBaseUrl } : {}),
timeout: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
timeout: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
},
});
let operation = await client.models.generateVideos({
@@ -253,7 +265,8 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
if (attempt >= MAX_POLL_ATTEMPTS) {
throw new Error("Google video generation did not finish in time");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS });
operation = await client.operations.getVideosOperation({ operation });
}
if (operation.error) {

View File

@@ -24,9 +24,22 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: minimaxProviderHttpMocks.assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: ({
label,
timeoutMs,
}: {
label: string;
timeoutMs?: number;
}) => ({
label,
timeoutMs,
}),
fetchWithTimeout: minimaxProviderHttpMocks.fetchWithTimeoutMock,
postJsonRequest: minimaxProviderHttpMocks.postJsonRequestMock,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,
resolveProviderHttpRequestConfig: minimaxProviderHttpMocks.resolveProviderHttpRequestConfigMock,
waitProviderOperationPollInterval: async () => {},
}));
export function getMinimaxProviderHttpMocks() {

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -115,6 +118,10 @@ async function pollMinimaxVideo(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<MinimaxQueryResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `MiniMax video generation task ${params.taskId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const url = new URL(`${params.baseUrl}/v1/query/video_generation`);
url.searchParams.set("task_id", params.taskId);
@@ -124,7 +131,7 @@ async function pollMinimaxVideo(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "MiniMax video status request failed");
@@ -141,7 +148,7 @@ async function pollMinimaxVideo(params: {
case "Preparing":
case "Processing":
default:
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;
}
}
@@ -269,6 +276,10 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "MiniMax video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg),
@@ -305,7 +316,10 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
url: `${baseUrl}/v1/video_generation`,
headers,
body,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -321,7 +335,10 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
const completed = await pollMinimaxVideo({
taskId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
@@ -330,14 +347,20 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
const video = videoUrl
? await downloadVideoFromUrl({
url: videoUrl,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
})
: fileId
? await downloadVideoFromFileId({
fileId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
})

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -121,6 +124,10 @@ async function pollOpenAIVideo(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<OpenAIVideoResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `OpenAI video generation task ${params.videoId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/videos/${params.videoId}`,
@@ -128,7 +135,7 @@ async function pollOpenAIVideo(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "OpenAI video status request failed");
@@ -141,7 +148,7 @@ async function pollOpenAIVideo(params: {
normalizeOptionalString(payload.error?.message) || "OpenAI video generation failed",
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
}
throw new Error(`OpenAI video generation task ${params.videoId} did not finish in time`);
}
@@ -227,6 +234,10 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "OpenAI video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveConfiguredOpenAIBaseUrl(req.cfg),
@@ -270,7 +281,10 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
),
},
},
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -296,7 +310,10 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
headers: multipartHeaders,
body: form,
},
req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
).then((response) => ({
response,
@@ -315,7 +332,10 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
...(seconds ? { seconds } : {}),
...(size ? { size } : {}),
},
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -333,14 +353,20 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
const completed = await pollOpenAIVideo({
videoId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
const video = await downloadOpenAIVideo({
videoId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import {
normalizeLowercaseStringOrEmpty,
@@ -15,7 +18,6 @@ import type {
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
@@ -39,6 +41,8 @@ type RunwayTaskDetailResponse = {
failure?: string | { message?: string } | null;
};
type RunwaySourceAsset = Pick<GeneratedVideoAsset, "buffer" | "mimeType" | "url">;
const TEXT_ONLY_MODELS = new Set(["gen4.5", "veo3.1", "veo3.1_fast", "veo3"]);
const IMAGE_MODELS = new Set([
"gen4.5",
@@ -63,7 +67,7 @@ function toDataUrl(buffer: Buffer, mimeType: string): string {
}
function resolveSourceUri(
asset: VideoGenerationSourceAsset | undefined,
asset: RunwaySourceAsset | undefined,
fallbackMimeType: string,
): string | undefined {
if (!asset) {
@@ -197,6 +201,10 @@ async function pollRunwayTask(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<RunwayTaskDetailResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `Runway video generation task ${params.taskId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/v1/tasks/${params.taskId}`,
@@ -204,7 +212,7 @@ async function pollRunwayTask(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Runway video status request failed");
@@ -223,7 +231,7 @@ async function pollRunwayTask(params: {
case "RUNNING":
case "THROTTLED":
default:
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;
}
}
@@ -302,6 +310,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "Runway video generation",
});
const requestBody = buildCreateBody(req);
const endpoint = resolveEndpoint(req);
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
@@ -321,7 +333,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
url: `${baseUrl}${endpoint}`,
headers,
body: requestBody,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -336,7 +351,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
const completed = await pollRunwayTask({
taskId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
@@ -348,7 +366,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
}
const videos = await downloadRunwayVideos({
urls: outputUrls,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
});
return {

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -71,6 +74,10 @@ async function pollTogetherVideo(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<TogetherVideoResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `Together video generation task ${params.videoId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/videos/${params.videoId}`,
@@ -78,7 +85,7 @@ async function pollTogetherVideo(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Together video status request failed");
@@ -91,7 +98,7 @@ async function pollTogetherVideo(params: {
normalizeOptionalString(payload.error?.message) ?? "Together video generation failed",
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
}
throw new Error(`Together video generation task ${params.videoId} did not finish in time`);
}
@@ -165,6 +172,10 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "Together video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveTogetherVideoBaseUrl(req),
@@ -209,7 +220,10 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider
url: `${baseUrl}/videos`,
headers,
body,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -224,7 +238,10 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider
const completed = await pollTogetherVideo({
videoId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
@@ -234,7 +251,10 @@ export function buildTogetherVideoGenerationProvider(): VideoGenerationProvider
}
const video = await downloadTogetherVideo({
url: videoUrl,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
});
return {

View File

@@ -16,6 +16,7 @@ import { isTruthyEnvValue } from "../src/infra/env.js";
import { getShellEnvAppliedKeys, loadShellEnvFallback } from "../src/infra/shell-env.js";
import { encodePngRgba, fillPixel } from "../src/media/png-encode.js";
import { getProviderEnvVars } from "../src/secrets/provider-env-vars.js";
import { normalizeVideoGenerationDuration } from "../src/video-generation/duration-support.js";
import {
canRunBufferBackedImageToVideoLiveLane,
canRunBufferBackedVideoToVideoLiveLane,
@@ -28,6 +29,13 @@ import {
resolveLiveVideoResolution,
} from "../src/video-generation/live-test-helpers.js";
import { parseVideoGenerationModelRef } from "../src/video-generation/model-ref.js";
import type {
GeneratedVideoAsset,
VideoGenerationMode,
VideoGenerationModeCapabilities,
VideoGenerationProvider,
VideoGenerationRequest,
} from "../src/video-generation/types.js";
import {
registerProviderPlugin,
requireRegisteredProvider,
@@ -49,7 +57,22 @@ const REQUIRE_PROFILE_KEYS =
isLiveProfileKeyModeEnabled() || isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS);
const describeLive = LIVE ? describe : describe.skip;
const providerFilter = parseCsvFilter(process.env.OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS);
const defaultSkippedProviders = providerFilter
? null
: parseCsvFilter(process.env.OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS ?? "fal");
const envModelMap = parseProviderModelMap(process.env.OPENCLAW_LIVE_VIDEO_GENERATION_MODELS);
const RUN_FULL_VIDEO_MODES = isTruthyEnvValue(
process.env.OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES,
);
const LIVE_VIDEO_REQUESTED_DURATION_SECONDS = 1;
const LIVE_VIDEO_OPERATION_TIMEOUT_MS = readPositiveIntegerEnv(
process.env.OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS,
180_000,
);
const LIVE_VIDEO_TEST_TIMEOUT_MS =
(RUN_FULL_VIDEO_MODES ? 3 : 1) * LIVE_VIDEO_OPERATION_TIMEOUT_MS + 30_000;
const LIVE_VIDEO_SMOKE_PROMPT =
"A one-second low-motion video of a lobster walking across wet sand, no text.";
type LiveProviderCase = {
plugin: Parameters<typeof registerProviderPlugin>[0]["plugin"];
@@ -58,6 +81,14 @@ type LiveProviderCase = {
providerId: string;
};
type BufferedGeneratedVideo = Required<Pick<GeneratedVideoAsset, "buffer" | "mimeType">> &
Pick<GeneratedVideoAsset, "fileName">;
type LiveVideoAttemptStatus =
| { status: "success"; video: BufferedGeneratedVideo }
| { status: "skip" }
| { status: "failure" };
const CASES: LiveProviderCase[] = [
{
plugin: alibabaPlugin,
@@ -92,8 +123,16 @@ const CASES: LiveProviderCase[] = [
{ plugin: xaiPlugin, pluginId: "xai", pluginName: "xAI Plugin", providerId: "xai" },
]
.filter((entry) => (providerFilter ? providerFilter.has(entry.providerId) : true))
.filter((entry) =>
defaultSkippedProviders ? !defaultSkippedProviders.has(entry.providerId) : true,
)
.toSorted((left, right) => left.providerId.localeCompare(right.providerId));
function readPositiveIntegerEnv(raw: string | undefined, fallback: number): number {
const value = Number.parseInt(raw?.trim() ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function withPluginsEnabled(cfg: OpenClawConfig): OpenClawConfig {
return {
...cfg,
@@ -157,6 +196,34 @@ function maybeLoadShellEnvForVideoProviders(providerIds: string[]): void {
});
}
function expectBufferedVideo(
video: { buffer?: Buffer; mimeType: string; fileName?: string } | undefined,
): BufferedGeneratedVideo {
expect(video).toBeDefined();
expect(video?.mimeType.startsWith("video/")).toBe(true);
if (!video?.buffer) {
throw new Error("expected generated video buffer");
}
const { buffer, mimeType, fileName } = video;
expect(buffer.byteLength).toBeGreaterThan(1024);
return { buffer, mimeType, fileName };
}
function buildLiveCapabilityOverrides(params: {
caps: VideoGenerationModeCapabilities | undefined;
liveResolution: VideoGenerationRequest["resolution"];
liveSize: string | undefined;
}): Pick<VideoGenerationRequest, "size" | "aspectRatio" | "resolution" | "audio" | "watermark"> {
const { caps, liveResolution, liveSize } = params;
return {
...(caps?.supportsSize && liveSize ? { size: liveSize } : {}),
...(caps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(caps?.supportsResolution ? { resolution: liveResolution } : {}),
...(caps?.supportsAudio ? { audio: false } : {}),
...(caps?.supportsWatermark ? { watermark: false } : {}),
};
}
function resolveLiveVideoSkipReason(message: string): string | null {
if (isAuthErrorMessage(message)) {
return "auth drift";
@@ -177,20 +244,96 @@ function resolveLiveVideoSkipReason(message: string): string | null {
if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) {
return "provider outage";
}
if (/access denied|not authorized|not enabled|permission denied/i.test(message)) {
return "provider/model drift";
}
return null;
}
function expectBufferedVideo(
video: { buffer?: Buffer; mimeType: string; fileName?: string } | undefined,
): { buffer: Buffer; mimeType: string; fileName?: string } {
expect(video).toBeDefined();
expect(video?.mimeType.startsWith("video/")).toBe(true);
if (!video?.buffer) {
throw new Error("expected generated video buffer");
async function runLiveVideoAttempt(params: {
authLabel: string;
attempted: string[];
failures: string[];
logPrefix: string;
mode: VideoGenerationMode;
provider: VideoGenerationProvider;
providerId: string;
providerModel: string;
request: VideoGenerationRequest;
skipped: string[];
}): Promise<LiveVideoAttemptStatus> {
const startedAt = Date.now();
console.error(`${params.logPrefix} mode=${params.mode} start auth=${params.authLabel}`);
try {
const result = await params.provider.generateVideo(params.request);
expect(result.videos.length).toBeGreaterThan(0);
const video = expectBufferedVideo(result.videos[0]);
params.attempted.push(
`${params.providerId}:${params.mode}:${params.providerModel} (${params.authLabel})`,
);
console.error(
`${params.logPrefix} mode=${params.mode} done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
return { status: "success", video };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
params.skipped.push(
`${params.providerId}:${params.mode} (${params.authLabel}): ${skipReason}`,
);
console.warn(
`${params.logPrefix} mode=${params.mode} skip reason=${skipReason} error=${message}`,
);
return { status: "skip" };
}
params.failures.push(`${params.providerId}:${params.mode} (${params.authLabel}): ${message}`);
console.error(`${params.logPrefix} mode=${params.mode} failed error=${message}`);
return { status: "failure" };
}
const { buffer, mimeType, fileName } = video;
expect(buffer.byteLength).toBeGreaterThan(1024);
return { buffer, mimeType, fileName };
}
function logLiveVideoSummary(params: {
attempted: string[];
failures: string[];
providerId: string;
skipped: string[];
}): void {
console.log(
`[live:video-generation] provider=${params.providerId} attempted=${params.attempted.join(", ") || "none"} skipped=${params.skipped.join(", ") || "none"} failures=${params.failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
}
function expectLiveVideoCasePassed(params: {
attempted: string[];
failures: string[];
providerId: string;
skipped: string[];
}): void {
logLiveVideoSummary(params);
if (params.attempted.length === 0) {
expect(params.failures).toEqual([]);
console.warn("[live:video-generation] no live video attempt completed; skipping assertions");
return;
}
expect(params.failures).toEqual([]);
}
function resolveLiveSmokeDurationSeconds(params: {
provider: Parameters<typeof normalizeVideoGenerationDuration>[0]["provider"];
model: string;
inputImageCount?: number;
inputVideoCount?: number;
}): number {
return (
normalizeVideoGenerationDuration({
provider: params.provider,
model: params.model,
durationSeconds: LIVE_VIDEO_REQUESTED_DURATION_SECONDS,
inputImageCount: params.inputImageCount ?? 0,
inputVideoCount: params.inputVideoCount ?? 0,
}) ?? LIVE_VIDEO_REQUESTED_DURATION_SECONDS
);
}
async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<void> {
@@ -200,6 +343,7 @@ async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<voi
const attempted: string[] = [];
const skipped: string[] = [];
const failures: string[] = [];
const summaryParams = { attempted, failures, providerId: testCase.providerId, skipped };
maybeLoadShellEnvForVideoProviders([testCase.providerId]);
@@ -209,9 +353,7 @@ async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<voi
DEFAULT_LIVE_VIDEO_MODELS[testCase.providerId];
if (!modelRef) {
skipped.push(`${testCase.providerId}: no model configured`);
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expectLiveVideoCasePassed(summaryParams);
return;
}
@@ -231,9 +373,7 @@ async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<voi
authLabel = `${auth.source} ${redactLiveApiKey(auth.apiKey)}`;
} catch {
skipped.push(`${testCase.providerId}: no usable auth`);
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expectLiveVideoCasePassed(summaryParams);
return;
}
@@ -247,179 +387,178 @@ async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<voi
const generateCaps = provider.capabilities.generate;
const imageToVideoCaps = provider.capabilities.imageToVideo;
const videoToVideoCaps = provider.capabilities.videoToVideo;
const durationSeconds = Math.min(generateCaps?.maxDurationSeconds ?? 3, 3);
const durationSeconds = resolveLiveSmokeDurationSeconds({
provider,
model: providerModel,
});
const liveResolution = resolveLiveVideoResolution({
providerId: testCase.providerId,
modelRef,
});
const liveSize = testCase.providerId === "openai" ? "1280x720" : undefined;
const logPrefix = `[live:video-generation] provider=${testCase.providerId} model=${providerModel}`;
let generatedVideo = null as {
buffer: Buffer;
mimeType: string;
fileName?: string;
} | null;
let generatedVideo: BufferedGeneratedVideo | null = null;
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=generate start auth=${authLabel}`);
const result = await provider.generateVideo({
const generateAttempt = await runLiveVideoAttempt({
authLabel,
attempted,
failures,
logPrefix,
mode: "generate",
provider,
providerId: testCase.providerId,
providerModel,
request: {
provider: testCase.providerId,
model: providerModel,
prompt: "A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
prompt: LIVE_VIDEO_SMOKE_PROMPT,
cfg,
agentDir,
authStore,
timeoutMs: LIVE_VIDEO_OPERATION_TIMEOUT_MS,
durationSeconds,
...(generateCaps?.supportsSize && liveSize ? { size: liveSize } : {}),
...(generateCaps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(generateCaps?.supportsResolution ? { resolution: liveResolution } : {}),
...(generateCaps?.supportsAudio ? { audio: false } : {}),
...(generateCaps?.supportsWatermark ? { watermark: false } : {}),
});
...buildLiveCapabilityOverrides({ caps: generateCaps, liveResolution, liveSize }),
},
skipped,
});
if (generateAttempt.status === "skip" || generateAttempt.status === "failure") {
expectLiveVideoCasePassed(summaryParams);
return;
}
generatedVideo = generateAttempt.video;
expect(result.videos.length).toBeGreaterThan(0);
generatedVideo = expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:generate:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=generate done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:generate (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=generate skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:generate (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=generate failed error=${message}`);
}
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expect(failures).toEqual([]);
if (!RUN_FULL_VIDEO_MODES) {
expectLiveVideoCasePassed(summaryParams);
return;
}
if (imageToVideoCaps?.enabled) {
if (
!canRunBufferBackedImageToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`,
);
} else {
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=imageToVideo start auth=${authLabel}`);
const referenceImage =
testCase.providerId === "openai"
? createEditReferencePng({ width: 1280, height: 720 })
: createEditReferencePng();
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt:
"Animate the reference art with subtle parallax motion and drifting camera movement.",
cfg,
agentDir,
authStore,
durationSeconds,
...(imageToVideoCaps.supportsSize && liveSize ? { size: liveSize } : {}),
inputImages: [
{
buffer: referenceImage,
mimeType: "image/png",
fileName: "reference.png",
},
],
...(imageToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(imageToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(imageToVideoCaps.supportsAudio ? { audio: false } : {}),
...(imageToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:imageToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=imageToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=imageToVideo skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=imageToVideo failed error=${message}`);
}
}
}
if (!imageToVideoCaps?.enabled) {
expectLiveVideoCasePassed(summaryParams);
return;
}
if (
!canRunBufferBackedImageToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`);
expectLiveVideoCasePassed(summaryParams);
return;
}
if (videoToVideoCaps?.enabled) {
if (
!canRunBufferBackedVideoToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(
`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`,
);
} else if (!generatedVideo?.buffer) {
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
} else {
try {
const startedAt = Date.now();
console.error(`${logPrefix} mode=videoToVideo start auth=${authLabel}`);
const result = await provider.generateVideo({
provider: testCase.providerId,
model: providerModel,
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
cfg,
agentDir,
authStore,
durationSeconds: Math.min(videoToVideoCaps.maxDurationSeconds ?? durationSeconds, 3),
inputVideos: [generatedVideo],
...(videoToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
...(videoToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
...(videoToVideoCaps.supportsAudio ? { audio: false } : {}),
...(videoToVideoCaps.supportsWatermark ? { watermark: false } : {}),
});
expect(result.videos.length).toBeGreaterThan(0);
expectBufferedVideo(result.videos[0]);
attempted.push(`${testCase.providerId}:videoToVideo:${providerModel} (${authLabel})`);
console.error(
`${logPrefix} mode=videoToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const skipReason = resolveLiveVideoSkipReason(message);
if (skipReason) {
skipped.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${skipReason}`);
console.error(`${logPrefix} mode=videoToVideo skip (${skipReason}) error=${message}`);
} else {
failures.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${message}`);
console.error(`${logPrefix} mode=videoToVideo failed error=${message}`);
}
}
}
const referenceImage =
testCase.providerId === "openai"
? createEditReferencePng({ width: 1280, height: 720 })
: createEditReferencePng();
const imageAttempt = await runLiveVideoAttempt({
authLabel,
attempted,
failures,
logPrefix,
mode: "imageToVideo",
provider,
providerId: testCase.providerId,
providerModel,
request: {
provider: testCase.providerId,
model: providerModel,
prompt: "Animate the reference art with subtle parallax motion and drifting camera movement.",
cfg,
agentDir,
authStore,
timeoutMs: LIVE_VIDEO_OPERATION_TIMEOUT_MS,
durationSeconds: resolveLiveSmokeDurationSeconds({
provider,
model: providerModel,
inputImageCount: 1,
}),
inputImages: [
{
buffer: referenceImage,
mimeType: "image/png",
fileName: "reference.png",
},
],
...buildLiveCapabilityOverrides({
caps: imageToVideoCaps,
liveResolution,
liveSize,
}),
},
skipped,
});
if (imageAttempt.status === "skip" || imageAttempt.status === "failure") {
expectLiveVideoCasePassed(summaryParams);
return;
}
console.log(
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
);
expect(failures).toEqual([]);
if (!videoToVideoCaps?.enabled) {
expectLiveVideoCasePassed(summaryParams);
return;
}
if (
!canRunBufferBackedVideoToVideoLiveLane({
providerId: testCase.providerId,
modelRef,
})
) {
skipped.push(`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`);
expectLiveVideoCasePassed(summaryParams);
return;
}
if (!generatedVideo?.buffer) {
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
expectLiveVideoCasePassed(summaryParams);
return;
}
const videoAttempt = await runLiveVideoAttempt({
authLabel,
attempted,
failures,
logPrefix,
mode: "videoToVideo",
provider,
providerId: testCase.providerId,
providerModel,
request: {
provider: testCase.providerId,
model: providerModel,
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
cfg,
agentDir,
authStore,
timeoutMs: LIVE_VIDEO_OPERATION_TIMEOUT_MS,
durationSeconds: resolveLiveSmokeDurationSeconds({
provider,
model: providerModel,
inputVideoCount: 1,
}),
inputVideos: [generatedVideo],
...buildLiveCapabilityOverrides({
caps: videoToVideoCaps,
liveResolution,
liveSize: undefined,
}),
},
skipped,
});
if (videoAttempt.status === "skip" || videoAttempt.status === "failure") {
expectLiveVideoCasePassed(summaryParams);
return;
}
expectLiveVideoCasePassed(summaryParams);
}
describeLive("video generation provider live", () => {
if (CASES.length === 0) {
it("skips when no video generation providers are selected", () => {
expect(CASES).toHaveLength(0);
});
}
for (const testCase of CASES) {
// One provider per test keeps cumulative suite runtime from tripping a single timeout cap.
it(
@@ -427,7 +566,7 @@ describeLive("video generation provider live", () => {
async () => {
await runLiveVideoProviderCase(testCase);
},
15 * 60_000,
LIVE_VIDEO_TEST_TIMEOUT_MS,
);
}
});

View File

@@ -2,8 +2,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import {
normalizeLowercaseStringOrEmpty,
@@ -247,6 +250,10 @@ export async function waitForVydraJob(params: {
fetchFn: typeof fetch;
kind: VydraMediaKind;
}): Promise<unknown> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `Vydra job ${params.jobId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/jobs/${params.jobId}`,
@@ -254,7 +261,7 @@ export async function waitForVydraJob(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Vydra job status request failed");
@@ -266,7 +273,7 @@ export async function waitForVydraJob(params: {
if (status === "failed" || status === "error" || status === "cancelled") {
throw new Error(resolveVydraErrorMessage(payload) ?? `Vydra job ${params.jobId} failed`);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
}
throw new Error(`Vydra job ${params.jobId} did not finish in time`);
}

View File

@@ -1,5 +1,10 @@
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
postJsonRequest,
resolveProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import {
DEFAULT_VYDRA_VIDEO_MODEL,
@@ -82,12 +87,19 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
authStore: req.authStore,
capability: "video",
});
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "Vydra video generation",
});
const { model, body } = resolveVydraVideoRequestBody(req);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}`,
headers,
body,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: 120_000,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -100,7 +112,10 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
submitted,
baseUrl,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: 120_000,
}),
fetchFn,
kind: "video",
missingJobIdMessage: "Vydra video generation response missing job id",
@@ -112,7 +127,10 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
const video = await downloadVydraAsset({
url: videoUrl,
kind: "video",
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: 120_000,
}),
fetchFn,
});
return {

View File

@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type {
@@ -202,6 +205,10 @@ async function pollXaiVideo(params: {
baseUrl: string;
fetchFn: typeof fetch;
}): Promise<XaiVideoStatusResponse> {
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `xAI video generation request ${params.requestId}`,
});
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/videos/${params.requestId}`,
@@ -209,7 +216,7 @@ async function pollXaiVideo(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "xAI video status request failed");
@@ -226,7 +233,7 @@ async function pollXaiVideo(params: {
case "queued":
case "processing":
default:
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
break;
}
}
@@ -305,6 +312,10 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
}
const fetchFn = fetch;
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs,
label: "xAI video generation",
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveXaiVideoBaseUrl(req),
@@ -322,7 +333,10 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
url: `${baseUrl}${resolveCreateEndpoint(req)}`,
headers,
body: buildCreateBody(req),
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
@@ -340,7 +354,10 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
const completed = await pollXaiVideo({
requestId,
headers,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
baseUrl,
fetchFn,
});
@@ -350,7 +367,10 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
}
const video = await downloadXaiVideo({
url: videoUrl,
timeoutMs: req.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
}),
fetchFn,
});
return {

View File

@@ -25,6 +25,7 @@ export type MediaSuiteConfig = {
testFile: string;
providerEnvVar: string;
providers: string[];
defaultProviders?: string[];
};
export const MEDIA_SUITES: Record<MediaSuiteId, MediaSuiteConfig> = {
@@ -57,6 +58,18 @@ export const MEDIA_SUITES: Record<MediaSuiteId, MediaSuiteConfig> = {
"vydra",
"xai",
],
defaultProviders: [
"alibaba",
"byteplus",
"google",
"minimax",
"openai",
"qwen",
"runway",
"together",
"vydra",
"xai",
],
},
};
@@ -213,9 +226,10 @@ function selectProviders(params: {
requireAuth: boolean;
}): string[] {
const explicit = params.suiteProviders ?? params.globalProviders;
let providers = params.suite.providers.filter((provider) =>
explicit ? explicit.has(provider) : true,
);
const candidates = explicit
? params.suite.providers
: (params.suite.defaultProviders ?? params.suite.providers);
let providers = candidates.filter((provider) => (explicit ? explicit.has(provider) : true));
if (!params.requireAuth) {
return providers;
}
@@ -275,6 +289,7 @@ Defaults:
- runs image + music + video
- auto-loads missing provider env vars from ~/.profile
- narrows each suite to providers that currently have usable auth
- skips the slow fal video smoke by default; pass --video-providers fal to run it
- forwards extra args to scripts/test-live.mjs
Flags:

View File

@@ -30,11 +30,14 @@ vi.mock("../infra/net/proxy-env.js", async () => {
});
import {
createProviderOperationDeadline,
fetchWithTimeoutGuarded,
postJsonRequest,
postTranscriptionRequest,
readErrorResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
} from "./shared.js";
beforeEach(() => {
@@ -44,6 +47,72 @@ beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe("provider operation deadlines", () => {
it("keeps default per-call timeouts when no operation timeout is configured", () => {
const deadline = createProviderOperationDeadline({
label: "video generation",
});
expect(resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: 60_000 })).toBe(60_000);
});
it("clamps per-call timeouts to the remaining operation deadline", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const deadline = createProviderOperationDeadline({
label: "video generation",
timeoutMs: 5_000,
});
vi.setSystemTime(4_250);
expect(resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: 60_000 })).toBe(1_750);
});
it("throws once the operation deadline has expired", () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const deadline = createProviderOperationDeadline({
label: "video generation",
timeoutMs: 2_000,
});
vi.setSystemTime(3_001);
expect(() => resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: 60_000 })).toThrow(
"video generation timed out after 2000ms",
);
});
it("clamps poll waits to the remaining operation deadline", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const deadline = createProviderOperationDeadline({
label: "video generation",
timeoutMs: 1_000,
});
const wait = waitProviderOperationPollInterval({
deadline,
pollIntervalMs: 10_000,
});
await vi.advanceTimersByTimeAsync(999);
let settled = false;
void wait.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(wait).resolves.toBeUndefined();
});
});
describe("resolveProviderHttpRequestConfig", () => {

View File

@@ -21,6 +21,62 @@ const MAX_ERROR_RESPONSE_BYTES = 4096;
const DEFAULT_GUARDED_HTTP_TIMEOUT_MS = 60_000;
const MAX_AUDIT_CONTEXT_CHARS = 80;
export type ProviderOperationDeadline = {
deadlineAtMs?: number;
label: string;
timeoutMs?: number;
};
export function createProviderOperationDeadline(params: {
timeoutMs?: number;
label: string;
}): ProviderOperationDeadline {
if (
typeof params.timeoutMs !== "number" ||
!Number.isFinite(params.timeoutMs) ||
params.timeoutMs <= 0
) {
return { label: params.label };
}
const timeoutMs = Math.floor(params.timeoutMs);
return {
deadlineAtMs: Date.now() + timeoutMs,
label: params.label,
timeoutMs,
};
}
export function resolveProviderOperationTimeoutMs(params: {
deadline: ProviderOperationDeadline;
defaultTimeoutMs: number;
}): number {
const deadlineAtMs = params.deadline.deadlineAtMs;
if (typeof deadlineAtMs !== "number") {
return params.defaultTimeoutMs;
}
const remainingMs = deadlineAtMs - Date.now();
if (remainingMs <= 0) {
throw new Error(`${params.deadline.label} timed out after ${params.deadline.timeoutMs}ms`);
}
return Math.max(1, Math.min(params.defaultTimeoutMs, remainingMs));
}
export async function waitProviderOperationPollInterval(params: {
deadline: ProviderOperationDeadline;
pollIntervalMs: number;
}): Promise<void> {
const deadlineAtMs = params.deadline.deadlineAtMs;
if (typeof deadlineAtMs !== "number") {
await new Promise((resolve) => setTimeout(resolve, params.pollIntervalMs));
return;
}
const remainingMs = deadlineAtMs - Date.now();
if (remainingMs <= 0) {
throw new Error(`${params.deadline.label} timed out after ${params.deadline.timeoutMs}ms`);
}
await new Promise((resolve) => setTimeout(resolve, Math.min(params.pollIntervalMs, remainingMs)));
}
function resolveGuardedHttpTimeoutMs(timeoutMs: number | undefined): number {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return DEFAULT_GUARDED_HTTP_TIMEOUT_MS;

View File

@@ -3,14 +3,18 @@
export {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
fetchWithTimeoutGuarded,
normalizeBaseUrl,
postJsonRequest,
postTranscriptionRequest,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
requireTranscriptionText,
waitProviderOperationPollInterval,
} from "../media-understanding/shared.js";
export type { ProviderOperationDeadline } from "../media-understanding/shared.js";
export type {
ProviderAttributionPolicy,
ProviderRequestCapabilities,

View File

@@ -43,7 +43,6 @@ describe("test-live-media", () => {
"minimax",
]);
expect(plan.find((entry) => entry.suite.id === "video")?.providers).toEqual([
"fal",
"google",
"minimax",
"openai",
@@ -54,12 +53,12 @@ describe("test-live-media", () => {
it("supports suite-specific provider filters without auth narrowing", async () => {
const { buildRunPlan, parseArgs } = await import("../../scripts/test-live-media.ts");
const plan = buildRunPlan(
parseArgs(["video", "--video-providers", "openai,runway", "--all-providers"]),
parseArgs(["video", "--video-providers", "fal,openai,runway", "--all-providers"]),
);
expect(plan).toHaveLength(1);
expect(plan[0]?.suite.id).toBe("video");
expect(plan[0]?.providers).toEqual(["openai", "runway"]);
expect(plan[0]?.providers).toEqual(["fal", "openai", "runway"]);
});
it("forwards quiet flags separately from passthrough args", async () => {

View File

@@ -1,7 +1,10 @@
import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
fetchWithTimeout,
postJsonRequest,
resolveProviderOperationTimeoutMs,
waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import type {
@@ -165,6 +168,11 @@ export async function pollDashscopeVideoTaskUntilComplete(params: {
baseUrl: string;
defaultTimeoutMs?: number;
}): Promise<DashscopeVideoGenerationResponse> {
const defaultTimeoutMs = params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS;
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `${params.providerLabel} video generation task ${params.taskId}`,
});
for (let attempt = 0; attempt < DEFAULT_VIDEO_GENERATION_MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/api/v1/tasks/${params.taskId}`,
@@ -172,7 +180,7 @@ export async function pollDashscopeVideoTaskUntilComplete(params: {
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
params.fetchFn,
);
await assertOkOrThrowHttpError(
@@ -191,7 +199,10 @@ export async function pollDashscopeVideoTaskUntilComplete(params: {
`${params.providerLabel} video generation task ${params.taskId} ${normalizeLowercaseStringOrEmpty(status)}`,
);
}
await new Promise((resolve) => setTimeout(resolve, DEFAULT_VIDEO_GENERATION_POLL_INTERVAL_MS));
await waitProviderOperationPollInterval({
deadline,
pollIntervalMs: DEFAULT_VIDEO_GENERATION_POLL_INTERVAL_MS,
});
}
throw new Error(
`${params.providerLabel} video generation task ${params.taskId} did not finish in time`,
@@ -211,6 +222,11 @@ export async function runDashscopeVideoGenerationTask(params: {
dispatcherPolicy?: Parameters<typeof postJsonRequest>[0]["dispatcherPolicy"];
defaultTimeoutMs?: number;
}): Promise<VideoGenerationResult> {
const defaultTimeoutMs = params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS;
const deadline = createProviderOperationDeadline({
timeoutMs: params.timeoutMs,
label: `${params.providerLabel} video generation`,
});
const { response, release } = await postJsonRequest({
url: params.url,
headers: params.headers,
@@ -228,7 +244,7 @@ export async function runDashscopeVideoGenerationTask(params: {
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
timeoutMs: params.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
@@ -245,10 +261,10 @@ export async function runDashscopeVideoGenerationTask(params: {
providerLabel: params.providerLabel,
taskId,
headers: params.headers,
timeoutMs: params.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
baseUrl: params.baseUrl,
defaultTimeoutMs: params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
defaultTimeoutMs,
});
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
@@ -259,9 +275,9 @@ export async function runDashscopeVideoGenerationTask(params: {
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: params.providerLabel,
urls,
timeoutMs: params.timeoutMs,
timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
defaultTimeoutMs: params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
defaultTimeoutMs,
});
return {
videos,

View File

@@ -24,9 +24,22 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
createProviderOperationDeadline: ({
label,
timeoutMs,
}: {
label: string;
timeoutMs?: number;
}) => ({
label,
timeoutMs,
}),
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
postJsonRequest: providerHttpMocks.postJsonRequestMock,
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
defaultTimeoutMs,
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
waitProviderOperationPollInterval: async () => {},
}));
export function getProviderHttpMocks() {