diff --git a/extensions/alibaba/video-generation-provider.test.ts b/extensions/alibaba/video-generation-provider.test.ts index 10efa0b07cc4..487bcade2edc 100644 --- a/extensions/alibaba/video-generation-provider.test.ts +++ b/extensions/alibaba/video-generation-provider.test.ts @@ -11,7 +11,13 @@ import { } from "openclaw/plugin-sdk/provider-test-contracts"; import { beforeAll, describe, expect, it } from "vitest"; -const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks(); +const { + postJsonRequestMock, + fetchWithTimeoutMock, + fetchWithTimeoutGuardedMock, + resolveProviderHttpRequestConfigMock, + sanitizeConfiguredModelProviderRequestMock, +} = getProviderHttpMocks(); let buildAlibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").buildAlibabaVideoGenerationProvider; @@ -74,6 +80,85 @@ describe("alibaba video generation provider", () => { expectSuccessfulDashscopeVideoResult(result); }); + it("applies configured request policy to DashScope video requests", async () => { + const requestPolicy = { + allowPrivateNetwork: true, + headers: { "X-DashScope-Route": "alibaba-policy" }, + }; + const dispatcherPolicy = { mode: "env-proxy" as const }; + resolveProviderHttpRequestConfigMock.mockImplementationOnce((params) => { + const headers = new Headers(params.defaultHeaders); + for (const [key, value] of Object.entries(params.request?.headers ?? {})) { + headers.set(key, value); + } + return { + baseUrl: params.baseUrl ?? params.defaultBaseUrl, + allowPrivateNetwork: params.request?.allowPrivateNetwork === true, + headers, + dispatcherPolicy, + }; + }); + mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock }); + + const provider = buildAlibabaVideoGenerationProvider(); + await provider.generateVideo({ + provider: "alibaba", + model: "wan2.6-t2v", + prompt: "animate this shot", + cfg: { + models: { + providers: { + alibaba: { + baseUrl: "https://dashscope-intl.aliyuncs.com", + models: [], + request: requestPolicy, + }, + }, + }, + }, + }); + + expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(requestPolicy); + expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "alibaba", + capability: "video", + transport: "http", + request: requestPolicy, + }), + ); + const request = requireFirstPostJsonRequest("DashScope request with request policy"); + expect(request.allowPrivateNetwork).toBe(true); + expect(request.dispatcherPolicy).toBe(dispatcherPolicy); + expect(request.headers).toBeInstanceOf(Headers); + expect((request.headers as Headers).get("x-dashscope-route")).toBe("alibaba-policy"); + expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( + 1, + "https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1", + expect.objectContaining({ + method: "GET", + headers: expect.any(Headers), + }), + 120_000, + fetch, + { + ssrfPolicy: { allowPrivateNetwork: true }, + dispatcherPolicy, + }, + ); + expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( + 2, + "https://example.com/out.mp4", + { method: "GET" }, + 120_000, + fetch, + { + ssrfPolicy: { allowPrivateNetwork: true }, + dispatcherPolicy, + }, + ); + }); + it("fails fast when reference inputs are local buffers instead of remote URLs", async () => { const provider = buildAlibabaVideoGenerationProvider(); diff --git a/extensions/alibaba/video-generation-provider.ts b/extensions/alibaba/video-generation-provider.ts index 3e4da50805ee..098c312fbf05 100644 --- a/extensions/alibaba/video-generation-provider.ts +++ b/extensions/alibaba/video-generation-provider.ts @@ -4,7 +4,10 @@ */ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; -import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http"; +import { + resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, +} from "openclaw/plugin-sdk/provider-http"; import { DASHSCOPE_WAN_VIDEO_CAPABILITIES, DASHSCOPE_WAN_VIDEO_MODELS, @@ -54,6 +57,7 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider { throw new Error("Alibaba Model Studio API key missing"); } + const providerConfig = req.cfg?.models?.providers?.alibaba; const requestBaseUrl = resolveAlibabaVideoBaseUrl(req); const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({ @@ -67,6 +71,7 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider { provider: "alibaba", capability: "video", transport: "http", + request: sanitizeConfiguredModelProviderRequest(providerConfig?.request), }); const model = req.model?.trim() || DEFAULT_ALIBABA_VIDEO_MODEL; diff --git a/extensions/qwen/video-generation-provider.test.ts b/extensions/qwen/video-generation-provider.test.ts index 3f39d49b21b8..9c359709b87a 100644 --- a/extensions/qwen/video-generation-provider.test.ts +++ b/extensions/qwen/video-generation-provider.test.ts @@ -11,7 +11,13 @@ import { } from "openclaw/plugin-sdk/provider-test-contracts"; import { beforeAll, describe, expect, it } from "vitest"; -const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks(); +const { + postJsonRequestMock, + fetchWithTimeoutMock, + fetchWithTimeoutGuardedMock, + resolveProviderHttpRequestConfigMock, + sanitizeConfiguredModelProviderRequestMock, +} = getProviderHttpMocks(); let buildQwenVideoGenerationProvider: typeof import("./video-generation-provider.js").buildQwenVideoGenerationProvider; @@ -104,6 +110,91 @@ describe("qwen video generation provider", () => { expectSuccessfulDashscopeVideoResult(result); }); + it("applies configured request policy to DashScope video requests", async () => { + const requestPolicy = { + allowPrivateNetwork: true, + headers: { "X-DashScope-Route": "qwen-policy" }, + }; + const dispatcherPolicy = { mode: "env-proxy" as const }; + resolveProviderHttpRequestConfigMock.mockImplementationOnce((params) => { + const headers = new Headers(params.defaultHeaders); + for (const [key, value] of Object.entries(params.request?.headers ?? {})) { + headers.set(key, value); + } + return { + baseUrl: params.baseUrl ?? params.defaultBaseUrl, + allowPrivateNetwork: params.request?.allowPrivateNetwork === true, + headers, + dispatcherPolicy, + }; + }); + mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock }); + + const provider = buildQwenVideoGenerationProvider(); + await provider.generateVideo({ + provider: "qwen", + model: "wan2.6-t2v", + prompt: "animate this shot", + cfg: { + models: { + providers: { + qwen: { + baseUrl: "https://dashscope-intl.aliyuncs.com", + models: [], + request: requestPolicy, + }, + }, + }, + }, + }); + + expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(requestPolicy); + expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "qwen", + capability: "video", + transport: "http", + request: requestPolicy, + }), + ); + const request = postJsonRequestMock.mock.calls[0]?.[0] as + | { + allowPrivateNetwork?: unknown; + dispatcherPolicy?: unknown; + headers?: Headers; + } + | undefined; + expect(request?.allowPrivateNetwork).toBe(true); + expect(request?.dispatcherPolicy).toBe(dispatcherPolicy); + expect(request?.headers).toBeInstanceOf(Headers); + expect(request?.headers?.get("x-dashscope-route")).toBe("qwen-policy"); + expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( + 1, + "https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1", + expect.objectContaining({ + method: "GET", + headers: expect.any(Headers), + }), + 120_000, + fetch, + { + ssrfPolicy: { allowPrivateNetwork: true }, + dispatcherPolicy, + }, + ); + expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith( + 2, + "https://example.com/out.mp4", + { method: "GET" }, + 120_000, + fetch, + { + ssrfPolicy: { allowPrivateNetwork: true }, + dispatcherPolicy, + }, + ); + }); + it("rejects DashScope video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ response: { diff --git a/extensions/qwen/video-generation-provider.ts b/extensions/qwen/video-generation-provider.ts index c4a5aa368cd9..8341cfa95fe1 100644 --- a/extensions/qwen/video-generation-provider.ts +++ b/extensions/qwen/video-generation-provider.ts @@ -1,7 +1,10 @@ // Qwen provider module implements model/runtime integration. import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; -import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http"; +import { + resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, +} from "openclaw/plugin-sdk/provider-http"; import { DASHSCOPE_WAN_VIDEO_CAPABILITIES, DASHSCOPE_WAN_VIDEO_MODELS, @@ -78,6 +81,7 @@ export function buildQwenVideoGenerationProvider(): VideoGenerationProvider { throw new Error("Qwen API key missing"); } + const providerConfig = req.cfg?.models?.providers?.qwen; const requestBaseUrl = resolveQwenVideoBaseUrl(req); const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({ @@ -91,6 +95,7 @@ export function buildQwenVideoGenerationProvider(): VideoGenerationProvider { provider: "qwen", capability: "video", transport: "http", + request: sanitizeConfiguredModelProviderRequest(providerConfig?.request), }); const model = req.model?.trim() || DEFAULT_QWEN_VIDEO_MODEL; diff --git a/src/plugin-sdk/test-helpers/provider-http-mocks.ts b/src/plugin-sdk/test-helpers/provider-http-mocks.ts index 8eec0244242c..438af63168ac 100644 --- a/src/plugin-sdk/test-helpers/provider-http-mocks.ts +++ b/src/plugin-sdk/test-helpers/provider-http-mocks.ts @@ -30,7 +30,7 @@ type ResolveProviderHttpRequestConfigResult = { baseUrl: string; allowPrivateNetwork: boolean; headers: Headers; - dispatcherPolicy: undefined; + dispatcherPolicy: ReturnType["dispatcherPolicy"]; }; type AnyMock = Mock<(...args: unknown[]) => unknown>; diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 6f22f2ac3d11..469ea2bfa632 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -4,8 +4,8 @@ import { assertOkOrThrowHttpError, createProviderOperationDeadline, createProviderOperationTimeoutResolver, - fetchProviderDownloadResponse, - fetchProviderOperationResponse, + executeProviderOperationWithRetry, + fetchWithTimeoutGuarded, postJsonRequest, readProviderJsonResponse, resolveProviderOperationTimeoutMs, @@ -180,6 +180,8 @@ export async function pollDashscopeVideoTaskUntilComplete(params: { timeoutMs?: number; fetchFn: typeof fetch; baseUrl: string; + allowPrivateNetwork?: boolean; + dispatcherPolicy?: Parameters[0]["dispatcherPolicy"]; defaultTimeoutMs?: number; }): Promise { const defaultTimeoutMs = params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS; @@ -188,22 +190,44 @@ export async function pollDashscopeVideoTaskUntilComplete(params: { label: `${params.providerLabel} video generation task ${params.taskId}`, }); for (let attempt = 0; attempt < DEFAULT_VIDEO_GENERATION_MAX_POLL_ATTEMPTS; attempt += 1) { - const response = await fetchProviderOperationResponse({ - stage: "poll", - url: `${params.baseUrl}/api/v1/tasks/${params.taskId}`, - init: { - method: "GET", - headers: params.headers, - }, - timeoutMs: createProviderOperationTimeoutResolver({ deadline, defaultTimeoutMs }), - fetchFn: params.fetchFn, + const pollResult = await executeProviderOperationWithRetry({ provider: params.providerLabel, - requestFailedMessage: `${params.providerLabel} video-generation task poll failed`, + stage: "poll", + operation: async () => { + const result = await fetchWithTimeoutGuarded( + `${params.baseUrl}/api/v1/tasks/${params.taskId}`, + { + method: "GET", + headers: params.headers, + }, + createProviderOperationTimeoutResolver({ deadline, defaultTimeoutMs })(), + params.fetchFn, + { + ...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}), + }, + ); + try { + await assertOkOrThrowHttpError( + result.response, + `${params.providerLabel} video-generation task poll failed`, + ); + return result; + } catch (error) { + await result.release(); + throw error; + } + }, }); - const payload = await readProviderJsonResponse( - response, - `${params.providerLabel} video-generation task poll`, - ); + let payload: DashscopeVideoGenerationResponse; + try { + payload = await readProviderJsonResponse( + pollResult.response, + `${params.providerLabel} video-generation task poll`, + ); + } finally { + await pollResult.release(); + } const status = payload.output?.task_status?.trim().toUpperCase(); if (status === "SUCCEEDED") { return payload; @@ -285,6 +309,8 @@ export async function runDashscopeVideoGenerationTask(params: { timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }), fetchFn: params.fetchFn, baseUrl: params.baseUrl, + allowPrivateNetwork: params.allowPrivateNetwork, + dispatcherPolicy: params.dispatcherPolicy, defaultTimeoutMs, }); const urls = extractDashscopeVideoUrls(completed); @@ -298,6 +324,8 @@ export async function runDashscopeVideoGenerationTask(params: { urls, timeoutMs: createProviderOperationTimeoutResolver({ deadline, defaultTimeoutMs }), fetchFn: params.fetchFn, + allowPrivateNetwork: params.allowPrivateNetwork, + dispatcherPolicy: params.dispatcherPolicy, defaultTimeoutMs, maxBytes: resolveGeneratedMediaMaxBytes(params.req.cfg, "video"), }); @@ -322,26 +350,55 @@ export async function downloadDashscopeGeneratedVideos(params: { urls: string[]; timeoutMs?: ProviderOperationTimeoutMs; fetchFn: typeof fetch; + allowPrivateNetwork?: boolean; + dispatcherPolicy?: Parameters[0]["dispatcherPolicy"]; defaultTimeoutMs?: number; maxBytes: number; }): Promise { const videos: GeneratedVideoAsset[] = []; for (const [index, url] of params.urls.entries()) { - const response = await fetchProviderDownloadResponse({ - url, - init: { method: "GET" }, - timeoutMs: params.timeoutMs ?? params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS, - fetchFn: params.fetchFn, + const result = await executeProviderOperationWithRetry({ provider: params.providerLabel, - requestFailedMessage: `${params.providerLabel} generated video download failed`, - }); - const buffer = await readResponseWithLimit(response, params.maxBytes, { - onOverflow: ({ maxBytes }) => - new Error(`${params.providerLabel} generated video download exceeds ${maxBytes} bytes`), + stage: "download", + operation: async () => { + const guarded = await fetchWithTimeoutGuarded( + url, + { method: "GET" }, + typeof params.timeoutMs === "function" + ? params.timeoutMs() + : (params.timeoutMs ?? params.defaultTimeoutMs ?? DEFAULT_VIDEO_GENERATION_TIMEOUT_MS), + params.fetchFn, + { + ...(params.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(params.dispatcherPolicy ? { dispatcherPolicy: params.dispatcherPolicy } : {}), + }, + ); + try { + await assertOkOrThrowHttpError( + guarded.response, + `${params.providerLabel} generated video download failed`, + ); + return guarded; + } catch (error) { + await guarded.release(); + throw error; + } + }, }); + let buffer: Buffer; + let mimeType: string; + try { + buffer = await readResponseWithLimit(result.response, params.maxBytes, { + onOverflow: ({ maxBytes }) => + new Error(`${params.providerLabel} generated video download exceeds ${maxBytes} bytes`), + }); + mimeType = result.response.headers.get("content-type")?.trim() || "video/mp4"; + } finally { + await result.release(); + } videos.push({ buffer, - mimeType: response.headers.get("content-type")?.trim() || "video/mp4", + mimeType, fileName: `video-${index + 1}.mp4`, metadata: { sourceUrl: url }, });