From ce905af28617676156f22f328526a36b9847892c Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Wed, 22 Jul 2026 12:49:40 +0800
Subject: [PATCH] fix(vydra): apply request policy to media generation requests
(#105884)
* fix(vydra): apply request policy to media generation requests
* fix(vydra): normalize body deadline aborts
---
.../vydra/image-generation-provider.test.ts | 36 ++++-
extensions/vydra/image-generation-provider.ts | 25 ++--
extensions/vydra/shared.test.ts | 36 +++++
extensions/vydra/shared.ts | 136 +++++++++++++-----
extensions/vydra/speech-provider.test.ts | 4 +-
extensions/vydra/speech-provider.ts | 6 +
.../vydra/video-generation-provider.test.ts | 67 +++++++++
extensions/vydra/video-generation-provider.ts | 22 +--
8 files changed, 270 insertions(+), 62 deletions(-)
diff --git a/extensions/vydra/image-generation-provider.test.ts b/extensions/vydra/image-generation-provider.test.ts
index 47298210a7fd..2143b1b8d6b1 100644
--- a/extensions/vydra/image-generation-provider.test.ts
+++ b/extensions/vydra/image-generation-provider.test.ts
@@ -116,13 +116,17 @@ describe("vydra image-generation provider", () => {
).rejects.toThrow("vydra.image-generation: JSON response exceeds 16777216 bytes");
});
- it("passes request SSRF policy to the image creation request", async () => {
+ it("passes request SSRF policy through image creation, polling, and download", async () => {
stubVydraApiKey();
const fetchMock = stubFetch(
+ jsonResponse({
+ jobId: "job-123",
+ status: "queued",
+ }),
jsonResponse({
jobId: "job-123",
status: "completed",
- imageUrl: "https://cdn.vydra.ai/generated/test.png",
+ imageUrl: "https://198.18.0.11/generated/test.png",
}),
binaryResponse("png-data", "image/png"),
);
@@ -137,6 +141,7 @@ describe("vydra image-generation provider", () => {
providers: {
vydra: {
baseUrl: "https://198.18.0.10/api/v1",
+ request: { headers: { "X-Vydra-Policy": "cross-origin" } },
},
},
},
@@ -147,6 +152,15 @@ describe("vydra image-generation provider", () => {
const createCall = fetchCall(fetchMock);
expect(createCall[0]).toBe("https://198.18.0.10/api/v1/models/grok-imagine");
expect(createCall[1].method).toBe("POST");
+ expect(new Headers(createCall[1].headers).get("x-vydra-policy")).toBe("cross-origin");
+ const pollCall = fetchCall(fetchMock, 1);
+ expect(pollCall[0]).toBe("https://198.18.0.10/api/v1/jobs/job-123");
+ expect(new Headers(pollCall[1].headers).get("x-vydra-policy")).toBe("cross-origin");
+ const downloadCall = fetchCall(fetchMock, 2);
+ expect(downloadCall[0]).toBe("https://198.18.0.11/generated/test.png");
+ const downloadHeaders = new Headers(downloadCall[1].headers);
+ expect(downloadHeaders.get("authorization")).toBeNull();
+ expect(downloadHeaders.get("x-vydra-policy")).toBeNull();
});
it("polls jobs when the create response is not completed yet", async () => {
@@ -156,7 +170,7 @@ describe("vydra image-generation provider", () => {
jsonResponse({
jobId: "job-456",
status: "completed",
- resultUrls: ["https://cdn.vydra.ai/generated/polled.png"],
+ resultUrls: ["https://www.vydra.ai/generated/polled.png"],
}),
binaryResponse("png-data", "image/png"),
);
@@ -166,12 +180,26 @@ describe("vydra image-generation provider", () => {
provider: "vydra",
model: "grok-imagine",
prompt: "draw a cat",
- cfg: {},
+ cfg: {
+ models: {
+ providers: {
+ vydra: {
+ baseUrl: "https://www.vydra.ai/api/v1",
+ models: [],
+ request: { headers: { "X-Vydra-Policy": "same-origin" } },
+ },
+ },
+ },
+ },
});
const pollCall = fetchCall(fetchMock, 1);
expect(pollCall[0]).toBe("https://www.vydra.ai/api/v1/jobs/job-456");
expect(pollCall[1].method).toBe("GET");
+ expect(new Headers(pollCall[1].headers).get("x-vydra-policy")).toBe("same-origin");
+ const downloadHeaders = new Headers(fetchCall(fetchMock, 2)[1].headers);
+ expect(downloadHeaders.get("authorization")).toBe("Bearer vydra-test-key");
+ expect(downloadHeaders.get("x-vydra-policy")).toBe("same-origin");
});
it("rejects job poll JSON responses that exceed the provider cap", async () => {
diff --git a/extensions/vydra/image-generation-provider.ts b/extensions/vydra/image-generation-provider.ts
index f583bcb3dc29..9f24cdffc581 100644
--- a/extensions/vydra/image-generation-provider.ts
+++ b/extensions/vydra/image-generation-provider.ts
@@ -54,27 +54,27 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
throw new Error("Vydra image generation supports at most one image per request.");
}
- const { fetchFn, baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
- await resolveVydraRequestContext({
- cfg: req.cfg,
- agentDir: req.agentDir,
- authStore: req.authStore,
- capability: "image",
- });
+ const { fetchFn, baseUrl, requestPolicy } = await resolveVydraRequestContext({
+ cfg: req.cfg,
+ agentDir: req.agentDir,
+ authStore: req.authStore,
+ capability: "image",
+ ssrfPolicy: req.ssrfPolicy,
+ });
const model = req.model?.trim() || DEFAULT_VYDRA_IMAGE_MODEL;
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}`,
- headers,
+ headers: requestPolicy.headers,
body: {
prompt: req.prompt,
model: "text-to-image",
},
timeoutMs: req.timeoutMs,
fetchFn,
- allowPrivateNetwork,
- ssrfPolicy: req.ssrfPolicy,
- dispatcherPolicy,
+ allowPrivateNetwork: requestPolicy.allowPrivateNetwork,
+ ssrfPolicy: requestPolicy.ssrfPolicy,
+ dispatcherPolicy: requestPolicy.dispatcherPolicy,
});
try {
@@ -83,11 +83,11 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
const completedPayload = await resolveCompletedVydraPayload({
submitted,
baseUrl,
- headers,
timeoutMs: req.timeoutMs,
fetchFn,
kind: "image",
missingJobIdMessage: "Vydra image generation response missing job id",
+ requestPolicy,
});
const imageUrl = extractVydraResultUrls(completedPayload, "image")[0];
if (!imageUrl) {
@@ -99,6 +99,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider {
timeoutMs: req.timeoutMs,
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "image" }),
+ requestPolicy,
});
return {
images: [
diff --git a/extensions/vydra/shared.test.ts b/extensions/vydra/shared.test.ts
index 035f51b6e7a7..1e395949c6bf 100644
--- a/extensions/vydra/shared.test.ts
+++ b/extensions/vydra/shared.test.ts
@@ -2,13 +2,22 @@
import { once } from "node:events";
import http from "node:http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
+import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-media-understanding";
import { afterEach, describe, expect, it } from "vitest";
import { downloadVydraAsset } from "./shared.js";
describe("downloadVydraAsset", () => {
+ installPinnedHostnameTestHooks();
+
let server: http.Server | undefined;
const dripTimers = new Set>();
+ const requestPolicyFor = (url: string, allowPrivateNetwork = false) => ({
+ allowPrivateNetwork,
+ headers: new Headers(),
+ headerOrigin: new URL(url).origin,
+ });
+
afterEach(async () => {
for (const timer of dripTimers) {
clearTimeout(timer);
@@ -71,6 +80,7 @@ describe("downloadVydraAsset", () => {
timeoutMs,
fetchFn: fetch,
maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor(`http://127.0.0.1:${port}`, true),
}),
).rejects.toThrow(`Vydra image download timed out after ${timeoutMs}ms`);
const elapsedMs = performance.now() - startedAt;
@@ -95,6 +105,7 @@ describe("downloadVydraAsset", () => {
timeoutMs,
fetchFn: fetch,
maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor(`http://127.0.0.1:${port}`, true),
}),
).rejects.toThrow(`Vydra image download timed out after ${timeoutMs}ms`);
const elapsedMs = performance.now() - startedAt;
@@ -117,6 +128,7 @@ describe("downloadVydraAsset", () => {
},
),
maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}).catch((error: unknown) => error);
expect(result).toMatchObject({
@@ -137,6 +149,7 @@ describe("downloadVydraAsset", () => {
timeoutMs: 250,
fetchFn: async () => new Response(null, { status: 304 }),
maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}).catch((error: unknown) => error);
expect(result).toMatchObject({ name: "ProviderHttpError", status: 304, statusCode: 304 });
@@ -160,6 +173,7 @@ describe("downloadVydraAsset", () => {
},
),
maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
}).catch((error: unknown) => error);
expect(result).toMatchObject({
@@ -170,6 +184,28 @@ describe("downloadVydraAsset", () => {
});
});
+ it("preserves successful response body errors before the deadline", async () => {
+ const result = await downloadVydraAsset({
+ url: "https://cdn.vydra.example/generated/test.png",
+ kind: "image",
+ timeoutMs: 250,
+ fetchFn: async () =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new Error("broken success body"));
+ },
+ }),
+ { status: 200 },
+ ),
+ maxBytes: 1024 * 1024,
+ requestPolicy: requestPolicyFor("https://cdn.vydra.example"),
+ }).catch((error: unknown) => error);
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result).toMatchObject({ message: "broken success body" });
+ });
+
it("does not bound a dripping body when only chunk idle timeout is used", async () => {
// Negative control: chunkTimeoutMs resets on every drip, so idle alone never fires.
const port = await listenDripServer({
diff --git a/extensions/vydra/shared.ts b/extensions/vydra/shared.ts
index 70da22d0a24a..1e9e08dd3c74 100644
--- a/extensions/vydra/shared.ts
+++ b/extensions/vydra/shared.ts
@@ -6,13 +6,15 @@ import {
assertOkOrThrowHttpError,
createProviderOperationDeadline,
createProviderOperationTimeoutResolver,
- fetchWithTimeout,
+ fetchWithTimeoutGuarded,
pollProviderOperationJson,
resolveProviderHttpRequestConfig,
+ sanitizeConfiguredModelProviderRequest,
type ProviderOperationDeadline,
type ProviderOperationTimeoutMs,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
+import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -31,6 +33,14 @@ const POLL_INTERVAL_MS = 2_500;
const MAX_POLL_ATTEMPTS = 120;
type VydraAuthStore = Parameters[0]["store"];
+type VydraRequestPolicy = Pick<
+ ReturnType,
+ "allowPrivateNetwork" | "dispatcherPolicy" | "headers"
+> & {
+ headerOrigin: string;
+ ssrfPolicy?: SsrFPolicy;
+};
+
type VydraMediaKind = Extract;
type VydraJobPayload = {
@@ -99,12 +109,11 @@ export async function resolveVydraRequestContext(params: {
agentDir?: string;
authStore?: VydraAuthStore;
capability: "image" | "video";
+ ssrfPolicy?: SsrFPolicy;
}): Promise<{
fetchFn: typeof fetch;
baseUrl: string;
- allowPrivateNetwork: boolean;
- headers: Headers;
- dispatcherPolicy: ReturnType["dispatcherPolicy"];
+ requestPolicy: VydraRequestPolicy;
}> {
const auth = await resolveApiKeyForProvider({
provider: "vydra",
@@ -116,11 +125,11 @@ export async function resolveVydraRequestContext(params: {
throw new Error("Vydra API key missing");
}
const fetchFn = fetch;
+ const providerConfig = params.cfg.models?.providers?.vydra;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveVydraBaseUrlFromConfig(params.cfg),
defaultBaseUrl: DEFAULT_VYDRA_BASE_URL,
- allowPrivateNetwork: false,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
@@ -128,13 +137,18 @@ export async function resolveVydraRequestContext(params: {
provider: "vydra",
capability: params.capability,
transport: "http",
+ request: sanitizeConfiguredModelProviderRequest(providerConfig?.request),
});
return {
fetchFn,
baseUrl,
- allowPrivateNetwork,
- headers,
- dispatcherPolicy,
+ requestPolicy: {
+ allowPrivateNetwork,
+ dispatcherPolicy,
+ headers,
+ headerOrigin: new URL(baseUrl).origin,
+ ...(params.ssrfPolicy ? { ssrfPolicy: params.ssrfPolicy } : {}),
+ },
};
}
@@ -221,6 +235,32 @@ function createVydraTimeoutError(deadline: ProviderOperationDeadline): Error {
return new Error(`${deadline.label} timed out${timeoutLabel}`);
}
+function resolveVydraGuardedRequestOptions(
+ policy: VydraRequestPolicy,
+): NonNullable[4]> {
+ const ssrfPolicy = policy.allowPrivateNetwork
+ ? { ...policy.ssrfPolicy, allowPrivateNetwork: true }
+ : policy.ssrfPolicy;
+ return {
+ ...(ssrfPolicy ? { ssrfPolicy } : {}),
+ ...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
+ auditContext: "vydra-media-download",
+ };
+}
+
+function resolveVydraAssetRequestHeaders(
+ url: string,
+ policy: VydraRequestPolicy,
+): Headers | undefined {
+ try {
+ // Same-origin assets may need the configured provider headers. Cross-origin
+ // result URLs must not receive the Vydra API credential or custom headers.
+ return new URL(url).origin === policy.headerOrigin ? policy.headers : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
export function resolveVydraGeneratedMediaMaxBytes(params: {
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
kind: VydraMediaKind;
@@ -244,6 +284,7 @@ export async function downloadVydraAsset(params: {
timeoutMs?: ProviderOperationTimeoutMs;
fetchFn: typeof fetch;
maxBytes: number;
+ requestPolicy: VydraRequestPolicy;
}): Promise<{ buffer: Buffer; mimeType: string; fileName: string }> {
const timeoutMs = resolveVydraHttpTimeoutMs(params.timeoutMs);
const deadline = createProviderOperationDeadline({
@@ -254,42 +295,65 @@ export async function downloadVydraAsset(params: {
deadline,
defaultTimeoutMs: timeoutMs,
});
- const response = await fetchWithTimeout(
+ const headers = resolveVydraAssetRequestHeaders(params.url, params.requestPolicy);
+ const result = await fetchWithTimeoutGuarded(
params.url,
- { method: "GET" },
+ {
+ method: "GET",
+ ...(headers ? { headers } : {}),
+ },
resolveTimeoutMs(),
params.fetchFn,
+ resolveVydraGuardedRequestOptions(params.requestPolicy),
);
- await assertOkOrThrowHttpError(response, `Vydra ${params.kind} download failed`, {
- bodyTimeoutMs: resolveTimeoutMs,
- onBodyTimeout: () => createVydraTimeoutError(deadline),
- });
- const mimeType =
- response.headers.get("content-type")?.trim() ||
- (params.kind === "image" ? "image/png" : params.kind === "audio" ? "audio/mpeg" : "video/mp4");
- const buffer = await readResponseWithLimit(response, params.maxBytes, {
- timeoutMs: resolveTimeoutMs,
- onTimeout: () => createVydraTimeoutError(deadline),
- onOverflow: ({ maxBytes }) =>
- new Error(`Vydra ${params.kind} download exceeds ${maxBytes} bytes`),
- });
- const extension = resolveVydraFileExtension(params.kind, mimeType);
- const fileStem = params.kind === "image" ? "image" : params.kind === "audio" ? "audio" : "video";
- return {
- buffer,
- mimeType,
- fileName: `${fileStem}-1.${extension}`,
- };
+ try {
+ try {
+ await assertOkOrThrowHttpError(result.response, `Vydra ${params.kind} download failed`, {
+ bodyTimeoutMs: resolveTimeoutMs,
+ onBodyTimeout: () => createVydraTimeoutError(deadline),
+ });
+ const mimeType =
+ result.response.headers.get("content-type")?.trim() ||
+ (params.kind === "image"
+ ? "image/png"
+ : params.kind === "audio"
+ ? "audio/mpeg"
+ : "video/mp4");
+ const buffer = await readResponseWithLimit(result.response, params.maxBytes, {
+ timeoutMs: resolveTimeoutMs,
+ onTimeout: () => createVydraTimeoutError(deadline),
+ onOverflow: ({ maxBytes }) =>
+ new Error(`Vydra ${params.kind} download exceeds ${maxBytes} bytes`),
+ });
+ const extension = resolveVydraFileExtension(params.kind, mimeType);
+ const fileStem =
+ params.kind === "image" ? "image" : params.kind === "audio" ? "audio" : "video";
+ return {
+ buffer,
+ mimeType,
+ fileName: `${fileStem}-1.${extension}`,
+ };
+ } catch (error) {
+ // The guarded request signal remains active through body consumption and
+ // can win the same absolute-deadline race. Keep timeout precedence stable.
+ if (typeof deadline.deadlineAtMs === "number" && Date.now() >= deadline.deadlineAtMs) {
+ throw createVydraTimeoutError(deadline);
+ }
+ throw error;
+ }
+ } finally {
+ await result.release();
+ }
}
async function waitForVydraJob(params: {
baseUrl: string;
jobId: string;
- headers: Headers;
timeoutMs?: number;
deadline?: ProviderOperationDeadline;
fetchFn: typeof fetch;
kind: VydraMediaKind;
+ requestPolicy: VydraRequestPolicy;
}): Promise {
const deadline =
params.deadline ??
@@ -299,7 +363,7 @@ async function waitForVydraJob(params: {
});
return await pollProviderOperationJson({
url: `${params.baseUrl}/jobs/${params.jobId}`,
- headers: params.headers,
+ headers: params.requestPolicy.headers,
deadline,
defaultTimeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
fetchFn: params.fetchFn,
@@ -307,6 +371,10 @@ async function waitForVydraJob(params: {
pollIntervalMs: POLL_INTERVAL_MS,
requestFailedMessage: "Vydra job status request failed",
timeoutMessage: `Vydra job ${params.jobId} did not finish in time`,
+ allowPrivateNetwork: params.requestPolicy.allowPrivateNetwork,
+ ssrfPolicy: params.requestPolicy.ssrfPolicy,
+ dispatcherPolicy: params.requestPolicy.dispatcherPolicy,
+ auditContext: "vydra-job-status",
isComplete: (payload) =>
resolveVydraResponseStatus(payload) === "completed" ||
extractVydraResultUrls(payload, params.kind).length > 0,
@@ -322,12 +390,12 @@ async function waitForVydraJob(params: {
export async function resolveCompletedVydraPayload(params: {
submitted: unknown;
baseUrl: string;
- headers: Headers;
timeoutMs?: number;
deadline?: ProviderOperationDeadline;
fetchFn: typeof fetch;
kind: VydraMediaKind;
missingJobIdMessage: string;
+ requestPolicy: VydraRequestPolicy;
}): Promise {
if (
resolveVydraResponseStatus(params.submitted) === "completed" ||
@@ -342,10 +410,10 @@ export async function resolveCompletedVydraPayload(params: {
return waitForVydraJob({
baseUrl: params.baseUrl,
jobId,
- headers: params.headers,
timeoutMs: params.timeoutMs,
...(params.deadline ? { deadline: params.deadline } : {}),
fetchFn: params.fetchFn,
kind: params.kind,
+ requestPolicy: params.requestPolicy,
});
}
diff --git a/extensions/vydra/speech-provider.test.ts b/extensions/vydra/speech-provider.test.ts
index f6422843cd4d..8ae30b4b48a8 100644
--- a/extensions/vydra/speech-provider.test.ts
+++ b/extensions/vydra/speech-provider.test.ts
@@ -42,7 +42,7 @@ describe("vydra speech provider", () => {
.mockResolvedValueOnce(
new Response(
JSON.stringify({
- audioUrl: "https://cdn.vydra.ai/generated/test.mp3",
+ audioUrl: "https://www.vydra.ai/generated/test.mp3",
}),
{
status: 200,
@@ -78,6 +78,8 @@ describe("vydra speech provider", () => {
);
const headers = new Headers(init.headers);
expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
+ const [, downloadInit] = fetchMock.mock.calls[1] as [string, RequestInit];
+ expect(new Headers(downloadInit.headers).get("authorization")).toBe("Bearer vydra-test-key");
expect(result.outputFormat).toBe("mp3");
expect(result.fileExtension).toBe(".mp3");
expect(result.audioBuffer).toEqual(Buffer.from("mp3-data"));
diff --git a/extensions/vydra/speech-provider.ts b/extensions/vydra/speech-provider.ts
index bc24b856d0ad..0ad1c33468c0 100644
--- a/extensions/vydra/speech-provider.ts
+++ b/extensions/vydra/speech-provider.ts
@@ -146,6 +146,12 @@ export function buildVydraSpeechProvider(): SpeechProviderPlugin {
timeoutMs: req.timeoutMs,
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "audio" }),
+ requestPolicy: {
+ allowPrivateNetwork,
+ dispatcherPolicy,
+ headers,
+ headerOrigin: new URL(baseUrl).origin,
+ },
});
return {
audioBuffer: audio.buffer,
diff --git a/extensions/vydra/video-generation-provider.test.ts b/extensions/vydra/video-generation-provider.test.ts
index beebda92a9b8..f788290638e9 100644
--- a/extensions/vydra/video-generation-provider.test.ts
+++ b/extensions/vydra/video-generation-provider.test.ts
@@ -1,4 +1,5 @@
// Vydra tests cover video generation provider plugin behavior.
+import * as providerHttp from "openclaw/plugin-sdk/provider-http";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-media-understanding";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -80,6 +81,72 @@ describe("vydra video-generation provider", () => {
});
});
+ it("carries configured request policy through video submit, poll, and download", async () => {
+ stubVydraApiKey();
+ const postJsonRequestSpy = vi.spyOn(providerHttp, "postJsonRequest");
+ const pollProviderOperationJsonSpy = vi.spyOn(providerHttp, "pollProviderOperationJson");
+ const fetchWithTimeoutGuardedSpy = vi.spyOn(providerHttp, "fetchWithTimeoutGuarded");
+ const fetchMock = stubFetch(
+ jsonResponse({ jobId: "job-policy", status: "processing" }),
+ jsonResponse({
+ jobId: "job-policy",
+ status: "completed",
+ videoUrl: "https://198.18.0.10/generated/policy.mp4",
+ }),
+ binaryResponse("mp4-data", "video/mp4"),
+ );
+
+ const provider = buildVydraVideoGenerationProvider();
+ await provider.generateVideo({
+ provider: "vydra",
+ model: "veo3",
+ prompt: "policy proof",
+ cfg: {
+ models: {
+ providers: {
+ vydra: {
+ baseUrl: "https://198.18.0.10/api/v1",
+ models: [],
+ request: {
+ allowPrivateNetwork: true,
+ headers: { "X-Vydra-Policy": "video-policy" },
+ proxy: { mode: "env-proxy" },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const submitRequest = postJsonRequestSpy.mock.calls[0]?.[0];
+ expect(submitRequest?.allowPrivateNetwork).toBe(true);
+ expect(submitRequest?.dispatcherPolicy).toMatchObject({ mode: "env-proxy" });
+ expect(submitRequest?.headers.get("x-vydra-policy")).toBe("video-policy");
+
+ const pollRequest = pollProviderOperationJsonSpy.mock.calls[0]?.[0];
+ expect(pollRequest?.allowPrivateNetwork).toBe(true);
+ expect(pollRequest?.dispatcherPolicy).toBe(submitRequest?.dispatcherPolicy);
+ const pollHeaders = new Headers(
+ typeof pollRequest?.headers === "function" ? pollRequest.headers() : pollRequest?.headers,
+ );
+ expect(pollHeaders.get("x-vydra-policy")).toBe("video-policy");
+
+ const downloadRequest = fetchWithTimeoutGuardedSpy.mock.calls.find(
+ ([url]) => url === "https://198.18.0.10/generated/policy.mp4",
+ );
+ expect(downloadRequest?.[4]).toMatchObject({
+ ssrfPolicy: { allowPrivateNetwork: true },
+ dispatcherPolicy: submitRequest?.dispatcherPolicy,
+ auditContext: "vydra-media-download",
+ });
+
+ for (const index of [0, 1, 2]) {
+ const headers = new Headers((fetchCall(fetchMock, index)[1] as RequestInit).headers);
+ expect(headers.get("authorization")).toBe("Bearer vydra-test-key");
+ expect(headers.get("x-vydra-policy")).toBe("video-policy");
+ }
+ });
+
it("rejects generated video downloads that exceed the configured media cap", async () => {
stubVydraApiKey();
stubFetch(
diff --git a/extensions/vydra/video-generation-provider.ts b/extensions/vydra/video-generation-provider.ts
index 02310b558b0a..01bdaab0b1c6 100644
--- a/extensions/vydra/video-generation-provider.ts
+++ b/extensions/vydra/video-generation-provider.ts
@@ -85,13 +85,12 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
throw new Error("Vydra video generation does not support video reference inputs.");
}
- const { fetchFn, baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
- await resolveVydraRequestContext({
- cfg: req.cfg,
- agentDir: req.agentDir,
- authStore: req.authStore,
- capability: "video",
- });
+ const { fetchFn, baseUrl, requestPolicy } = await resolveVydraRequestContext({
+ cfg: req.cfg,
+ agentDir: req.agentDir,
+ authStore: req.authStore,
+ capability: "video",
+ });
const deadline = createProviderOperationDeadline({
timeoutMs: req.timeoutMs ?? DEFAULT_VYDRA_VIDEO_TIMEOUT_MS,
label: "Vydra video generation",
@@ -99,15 +98,15 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
const { model, body } = resolveVydraVideoRequestBody(req);
const { response, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}`,
- headers,
+ headers: requestPolicy.headers,
body,
timeoutMs: resolveProviderOperationTimeoutMs({
deadline,
defaultTimeoutMs: DEFAULT_VYDRA_VIDEO_TIMEOUT_MS,
}),
fetchFn,
- allowPrivateNetwork,
- dispatcherPolicy,
+ allowPrivateNetwork: requestPolicy.allowPrivateNetwork,
+ dispatcherPolicy: requestPolicy.dispatcherPolicy,
});
try {
@@ -119,11 +118,11 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
const completedPayload = await resolveCompletedVydraPayload({
submitted,
baseUrl,
- headers,
deadline,
fetchFn,
kind: "video",
missingJobIdMessage: "Vydra video generation response missing job id",
+ requestPolicy,
});
const videoUrl = extractVydraResultUrls(completedPayload, "video")[0];
if (!videoUrl) {
@@ -138,6 +137,7 @@ export function buildVydraVideoGenerationProvider(): VideoGenerationProvider {
}),
fetchFn,
maxBytes: resolveVydraGeneratedMediaMaxBytes({ cfg: req.cfg, kind: "video" }),
+ requestPolicy,
});
return {
videos: [