mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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({
|
||||
|
||||
+102
-34
@@ -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<typeof resolveApiKeyForProvider>[0]["store"];
|
||||
|
||||
type VydraRequestPolicy = Pick<
|
||||
ReturnType<typeof resolveProviderHttpRequestConfig>,
|
||||
"allowPrivateNetwork" | "dispatcherPolicy" | "headers"
|
||||
> & {
|
||||
headerOrigin: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
};
|
||||
|
||||
type VydraMediaKind = Extract<MediaKind, "audio" | "image" | "video">;
|
||||
|
||||
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<typeof resolveProviderHttpRequestConfig>["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<Parameters<typeof fetchWithTimeoutGuarded>[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<unknown> {
|
||||
const deadline =
|
||||
params.deadline ??
|
||||
@@ -299,7 +363,7 @@ async function waitForVydraJob(params: {
|
||||
});
|
||||
return await pollProviderOperationJson<unknown>({
|
||||
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<unknown> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user