From 111b65a6fb4cdf1065ff78e0de843ee7c9a22f91 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 09:53:12 +0800 Subject: [PATCH] fix(providers): harden video response schemas --- CHANGELOG.md | 1 + .../fal/video-generation-provider.test.ts | 109 ++++++++++++ extensions/fal/video-generation-provider.ts | 166 ++++++++++++++---- .../video-generation-provider.test.ts | 83 +++++++++ .../openrouter/video-generation-provider.ts | 79 ++++++++- .../xai/video-generation-provider.test.ts | 91 ++++++++++ extensions/xai/video-generation-provider.ts | 59 ++++++- 7 files changed, 544 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a62051cd6c5..e505bffdb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Config persistence: strip malformed pending final-delivery session fields on load so replay/recovery paths skip poisoned reply metadata instead of crashing on raw objects. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results. +- Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling. - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. - Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart. - Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags. diff --git a/extensions/fal/video-generation-provider.test.ts b/extensions/fal/video-generation-provider.test.ts index 0b6dcba4acc..1354fbd0548 100644 --- a/extensions/fal/video-generation-provider.test.ts +++ b/extensions/fal/video-generation-provider.test.ts @@ -170,6 +170,115 @@ describe("fal video generation provider", () => { }); }); + it("wraps malformed successful fal submit responses", async () => { + mockFalProviderRuntime(); + fetchGuardMock.mockResolvedValueOnce(releasedJson([])); + + const provider = buildFalVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "bad shape", + cfg: {}, + }), + ).rejects.toThrow("fal video generation response malformed"); + }); + + it("wraps non-JSON successful fal submit responses", async () => { + mockFalProviderRuntime(); + fetchGuardMock.mockResolvedValueOnce({ + response: { + json: async () => { + throw new SyntaxError("Unexpected token < in JSON"); + }, + }, + release: vi.fn(async () => {}), + }); + + const provider = buildFalVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "html body", + cfg: {}, + }), + ).rejects.toThrow("fal video generation response malformed"); + }); + + it("rejects missing fal queue statuses without waiting for timeout", async () => { + mockFalProviderRuntime(); + fetchGuardMock + .mockResolvedValueOnce( + releasedJson({ + request_id: "req-123", + status_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status", + response_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123", + }), + ) + .mockResolvedValueOnce(releasedJson({})); + + const provider = buildFalVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "missing status", + cfg: {}, + }), + ).rejects.toThrow("fal video generation response malformed"); + expect(fetchGuardMock).toHaveBeenCalledTimes(2); + }); + + it("rejects unknown fal queue statuses without waiting for timeout", async () => { + mockFalProviderRuntime(); + fetchGuardMock + .mockResolvedValueOnce( + releasedJson({ + request_id: "req-123", + status_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status", + response_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123", + }), + ) + .mockResolvedValueOnce(releasedJson({ status: "ALMOST_DONE" })); + + const provider = buildFalVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "bad status", + cfg: {}, + }), + ).rejects.toThrow("fal video generation response malformed"); + expect(fetchGuardMock).toHaveBeenCalledTimes(2); + }); + + it("rejects malformed fal completed result payloads", async () => { + mockFalProviderRuntime(); + fetchGuardMock + .mockResolvedValueOnce( + releasedJson({ + request_id: "req-123", + status_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123/status", + response_url: "https://queue.fal.run/fal-ai/minimax/requests/req-123", + }), + ) + .mockResolvedValueOnce(releasedJson({ status: "COMPLETED" })) + .mockResolvedValueOnce(releasedJson({ status: "COMPLETED", response: [] })); + + const provider = buildFalVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "fal", + model: "fal-ai/minimax/video-01-live", + prompt: "bad result", + cfg: {}, + }), + ).rejects.toThrow("fal video generation response malformed"); + }); + it("exposes Seedance 2 models", () => { const provider = buildFalVideoGenerationProvider(); diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index 071395e76cc..438d1f9b4cf 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -55,6 +55,14 @@ const SEEDANCE_REFERENCE_MAX_AUDIOS_BY_MODEL = Object.fromEntries( const DEFAULT_HTTP_TIMEOUT_MS = 30_000; const DEFAULT_OPERATION_TIMEOUT_MS = 1_200_000; const POLL_INTERVAL_MS = 5_000; +const FAL_VIDEO_MALFORMED_RESPONSE = "fal video generation response malformed"; +const FAL_VIDEO_PENDING_STATUSES = new Set([ + "IN_QUEUE", + "IN_PROGRESS", + "PROCESSING", + "QUEUED", + "STARTED", +]); type FalVideoResponse = { video?: { @@ -89,6 +97,74 @@ export function _setFalVideoFetchGuardForTesting(impl: typeof fetchWithSsrFGuard falFetchGuard = impl ?? fetchWithSsrFGuard; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeFalVideoUrl(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized && value !== undefined && value !== null) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + return normalized; +} + +function readFalVideoPayload(payload: unknown): FalVideoResponse { + if (!isRecord(payload)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + const video = payload.video; + const videos = payload.videos; + if (video !== undefined && video !== null && !isRecord(video)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + if (videos !== undefined && videos !== null && !Array.isArray(videos)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + return { + video: isRecord(video) + ? { + url: normalizeFalVideoUrl(video.url), + content_type: normalizeOptionalString(video.content_type), + } + : undefined, + videos: Array.isArray(videos) + ? videos.map((entry) => { + if (!isRecord(entry)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + return { + url: normalizeFalVideoUrl(entry.url), + content_type: normalizeOptionalString(entry.content_type), + }; + }) + : undefined, + prompt: normalizeOptionalString(payload.prompt), + seed: typeof payload.seed === "number" ? payload.seed : undefined, + }; +} + +function readFalQueueResponse(payload: unknown): FalQueueResponse { + if (!isRecord(payload)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + const error = payload.error; + if (error !== undefined && error !== null && !isRecord(error)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + return { + status: normalizeOptionalString(payload.status), + request_id: normalizeOptionalString(payload.request_id), + response_url: normalizeOptionalString(payload.response_url), + status_url: normalizeOptionalString(payload.status_url), + cancel_url: normalizeOptionalString(payload.cancel_url), + detail: normalizeOptionalString(payload.detail), + response: payload.response === undefined ? undefined : readFalVideoPayload(payload.response), + prompt: normalizeOptionalString(payload.prompt), + error: isRecord(error) ? { message: normalizeOptionalString(error.message) } : undefined, + }; +} + function toDataUrl(buffer: Buffer, mimeType: string): string { return `data:${mimeType};base64,${buffer.toString("base64")}`; } @@ -355,7 +431,11 @@ async function fetchFalJson(params: { }); try { await assertOkOrThrowHttpError(response, params.errorContext); - return await response.json(); + try { + return await response.json(); + } catch { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } } finally { await release(); } @@ -372,25 +452,9 @@ async function waitForFalQueueResult(params: { const deadline = Date.now() + params.timeoutMs; let lastStatus = "unknown"; while (Date.now() < deadline) { - const payload = (await fetchFalJson({ - url: params.statusUrl, - init: { - method: "GET", - headers: params.headers, - }, - timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, - policy: params.policy, - dispatcherPolicy: params.dispatcherPolicy, - auditContext: "fal-video-status", - errorContext: "fal video status request failed", - })) as FalQueueResponse; - const status = normalizeOptionalString(payload.status)?.toUpperCase(); - if (status) { - lastStatus = status; - } - if (status === "COMPLETED") { - return (await fetchFalJson({ - url: params.responseUrl, + const payload = readFalQueueResponse( + await fetchFalJson({ + url: params.statusUrl, init: { method: "GET", headers: params.headers, @@ -398,9 +462,30 @@ async function waitForFalQueueResult(params: { timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, policy: params.policy, dispatcherPolicy: params.dispatcherPolicy, - auditContext: "fal-video-result", - errorContext: "fal video result request failed", - })) as FalQueueResponse; + auditContext: "fal-video-status", + errorContext: "fal video status request failed", + }), + ); + const status = normalizeOptionalString(payload.status)?.toUpperCase(); + if (!status) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } + lastStatus = status; + if (status === "COMPLETED") { + return readFalQueueResponse( + await fetchFalJson({ + url: params.responseUrl, + init: { + method: "GET", + headers: params.headers, + }, + timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, + policy: params.policy, + dispatcherPolicy: params.dispatcherPolicy, + auditContext: "fal-video-result", + errorContext: "fal video result request failed", + }), + ); } if (status === "FAILED" || status === "CANCELLED") { throw new Error( @@ -409,16 +494,19 @@ async function waitForFalQueueResult(params: { `fal video generation ${normalizeLowercaseStringOrEmpty(status)}`, ); } + if (!FAL_VIDEO_PENDING_STATUSES.has(status)) { + throw new Error(FAL_VIDEO_MALFORMED_RESPONSE); + } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } throw new Error(`fal video generation did not finish in time (last status: ${lastStatus})`); } function extractFalVideoPayload(payload: FalQueueResponse): FalVideoResponse { - if (payload.response && typeof payload.response === "object") { + if (payload.response) { return payload.response; } - return payload as FalVideoResponse; + return readFalVideoPayload(payload); } export function buildFalVideoGenerationProvider(): VideoGenerationProvider { @@ -509,19 +597,21 @@ export function buildFalVideoGenerationProvider(): VideoGenerationProvider { const requestBody = buildFalVideoRequestBody({ req, model }); const policy = buildPolicy(allowPrivateNetwork); const queueBaseUrl = resolveFalQueueBaseUrl(baseUrl); - const submitted = (await fetchFalJson({ - url: `${queueBaseUrl}/${model}`, - init: { - method: "POST", - headers, - body: JSON.stringify(requestBody), - }, - timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, - policy, - dispatcherPolicy, - auditContext: "fal-video-submit", - errorContext: "fal video generation failed", - })) as FalQueueResponse; + const submitted = readFalQueueResponse( + await fetchFalJson({ + url: `${queueBaseUrl}/${model}`, + init: { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }, + timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, + policy, + dispatcherPolicy, + auditContext: "fal-video-submit", + errorContext: "fal video generation failed", + }), + ); const statusUrl = normalizeOptionalString(submitted.status_url); const responseUrl = normalizeOptionalString(submitted.response_url); if (!statusUrl || !responseUrl) { diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index ee57a8f121c..aa7f5c0666a 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -553,6 +553,89 @@ describe("openrouter video generation provider", () => { }); }); + it("wraps malformed successful OpenRouter submit responses", async () => { + postJsonRequestMock.mockResolvedValue(releasedJson([])); + + const provider = buildOpenRouterVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "openrouter", + model: "google/veo-3.1", + prompt: "bad shape", + cfg: {} as never, + }), + ).rejects.toThrow("OpenRouter video generation response malformed"); + }); + + it("wraps non-JSON successful OpenRouter submit responses", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => { + throw new SyntaxError("Unexpected token < in JSON"); + }, + }, + release: vi.fn(async () => {}), + }); + + const provider = buildOpenRouterVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "openrouter", + model: "google/veo-3.1", + prompt: "html body", + cfg: {} as never, + }), + ).rejects.toThrow("OpenRouter video generation response malformed"); + }); + + it("rejects unknown OpenRouter poll statuses without waiting for timeout", async () => { + postJsonRequestMock.mockResolvedValue( + releasedJson({ + id: "job-123", + polling_url: "/api/v1/videos/job-123", + status: "pending", + }), + ); + fetchWithTimeoutGuardedMock.mockResolvedValueOnce( + releasedJson({ + id: "job-123", + status: "nearly_done", + }), + ); + + const provider = buildOpenRouterVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "openrouter", + model: "google/veo-3.1", + prompt: "bad status", + cfg: {} as never, + }), + ).rejects.toThrow("OpenRouter video generation response malformed"); + expect(waitProviderOperationPollIntervalMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed OpenRouter completed output URL arrays", async () => { + postJsonRequestMock.mockResolvedValue( + releasedJson({ + id: "job-123", + polling_url: "/api/v1/videos/job-123", + status: "completed", + unsigned_urls: { 0: "/api/v1/videos/job-123/content?index=0" }, + }), + ); + + const provider = buildOpenRouterVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "openrouter", + model: "google/veo-3.1", + prompt: "bad urls", + cfg: {} as never, + }), + ).rejects.toThrow("OpenRouter video generation response malformed"); + }); + it("does not forward auth headers to cross-origin polling URLs", async () => { postJsonRequestMock.mockResolvedValue( releasedJson({ diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index 45a0e8f4dea..a6348256947 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -33,6 +33,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 5_000; const MAX_POLL_ATTEMPTS = 120; const SUPPORTED_ASPECT_RATIOS = ["16:9", "9:16"] as const; +const OPENROUTER_VIDEO_MALFORMED_RESPONSE = "OpenRouter video generation response malformed"; const SUPPORTED_DURATION_SECONDS = [4, 6, 8] as const; // Runtime sets this after normalizing against live model capabilities. const SUPPORTED_DURATIONS_HINT = Symbol.for("openclaw.videoGeneration.supportedDurations"); @@ -61,6 +62,57 @@ type OpenRouterFrameImagePart = OpenRouterImagePart & { frame_type: "first_frame" | "last_frame"; }; +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +async function readOpenRouterVideoJson(response: Response): Promise> { + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + if (!isRecord(payload)) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + return payload; +} + +function readOpenRouterVideoResponse(payload: Record): OpenRouterVideoResponse { + const unsignedUrls = payload.unsigned_urls; + if (unsignedUrls !== undefined && unsignedUrls !== null && !Array.isArray(unsignedUrls)) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + const usage = payload.usage; + if (usage !== undefined && usage !== null && !isRecord(usage)) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + return { + id: normalizeOptionalString(payload.id), + generation_id: normalizeOptionalString(payload.generation_id) ?? null, + polling_url: normalizeOptionalString(payload.polling_url), + status: normalizeOptionalString(payload.status), + unsigned_urls: Array.isArray(unsignedUrls) + ? unsignedUrls.map((url) => { + const normalized = normalizeOptionalString(url); + if (!normalized) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + return normalized; + }) + : undefined, + error: normalizeOptionalString(payload.error) ?? null, + model: normalizeOptionalString(payload.model) ?? null, + usage: isRecord(usage) + ? { + cost: typeof usage.cost === "number" ? usage.cost : null, + is_byok: typeof usage.is_byok === "boolean" ? usage.is_byok : undefined, + } + : undefined, + }; +} + function toDataUrl(asset: VideoGenerationSourceAsset): string { if (asset.buffer) { const mimeType = normalizeOptionalString(asset.mimeType) ?? "image/png"; @@ -223,7 +275,7 @@ async function fetchOpenRouterJson(params: { const { response, release } = await fetchOpenRouterVideoGet(params); try { await assertOkOrThrowHttpError(response, params.errorContext); - return (await response.json()) as OpenRouterVideoResponse; + return readOpenRouterVideoResponse(await readOpenRouterVideoJson(response)); } finally { await release(); } @@ -257,6 +309,13 @@ async function pollOpenRouterVideo(params: { auditContext: "openrouter-video-status", }); const status = normalizeOptionalString(payload.status); + if ( + !status || + (!["queued", "pending", "processing", "running", "completed"].includes(status) && + !isTerminalFailure(status)) + ) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } if (status === "completed") { return payload; } @@ -401,14 +460,28 @@ export function buildOpenRouterVideoGenerationProvider(): VideoGenerationProvide try { await assertOkOrThrowHttpError(response, "OpenRouter video generation failed"); - const submitted = (await response.json()) as OpenRouterVideoResponse; + const submitted = readOpenRouterVideoResponse(await readOpenRouterVideoJson(response)); const jobId = normalizeOptionalString(submitted.id); const pollingUrl = normalizeOptionalString(submitted.polling_url); if (!jobId || !pollingUrl) { throw new Error("OpenRouter video generation response missing job details"); } + const submittedStatus = normalizeOptionalString(submitted.status); + if ( + submittedStatus && + !["queued", "pending", "processing", "running", "completed"].includes(submittedStatus) && + !isTerminalFailure(submittedStatus) + ) { + throw new Error(OPENROUTER_VIDEO_MALFORMED_RESPONSE); + } + if (isTerminalFailure(submittedStatus)) { + throw new Error( + normalizeOptionalString(submitted.error) ?? + `OpenRouter video generation ${submittedStatus}`, + ); + } const completed = - normalizeOptionalString(submitted.status) === "completed" + submittedStatus === "completed" ? submitted : await pollOpenRouterVideo({ pollingUrl, diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts index e73b8160a1c..6fd11642c3e 100644 --- a/extensions/xai/video-generation-provider.test.ts +++ b/extensions/xai/video-generation-provider.test.ts @@ -104,6 +104,97 @@ describe("xai video generation provider", () => { expect(result.metadata?.mode).toBe("generate"); }); + it("wraps malformed successful xAI create responses", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => [], + }, + release: vi.fn(async () => {}), + }); + + const provider = buildXaiVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "xai", + model: "grok-imagine-video", + prompt: "bad shape", + cfg: {}, + }), + ).rejects.toThrow("xAI video generation response malformed"); + }); + + it("wraps non-JSON successful xAI create responses", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => { + throw new SyntaxError("Unexpected token < in JSON"); + }, + }, + release: vi.fn(async () => {}), + }); + + const provider = buildXaiVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "xai", + model: "grok-imagine-video", + prompt: "html body", + cfg: {}, + }), + ).rejects.toThrow("xAI video generation response malformed"); + }); + + it("rejects unknown xAI poll statuses without waiting for timeout", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ request_id: "req_bad_status" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + request_id: "req_bad_status", + status: "almost_done", + }), + }); + + const provider = buildXaiVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "xai", + model: "grok-imagine-video", + prompt: "bad status", + cfg: {}, + }), + ).rejects.toThrow("xAI video generation response malformed"); + }); + + it("rejects completed xAI poll responses without output URLs as malformed", async () => { + postJsonRequestMock.mockResolvedValue({ + response: { + json: async () => ({ request_id: "req_no_video" }), + }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ + json: async () => ({ + request_id: "req_no_video", + status: "done", + video: {}, + }), + }); + + const provider = buildXaiVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "xai", + model: "grok-imagine-video", + prompt: "missing video", + cfg: {}, + }), + ).rejects.toThrow("xAI video generation response malformed"); + }); + it("sends a single unroled image as xAI first-frame image-to-video", async () => { postJsonRequestMock.mockResolvedValue({ response: { diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 9dea19b6f90..d88e581026e 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -26,6 +26,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; const POLL_INTERVAL_MS = 5_000; const MAX_POLL_ATTEMPTS = 120; const XAI_VIDEO_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]); +const XAI_VIDEO_MALFORMED_RESPONSE = "xAI video generation response malformed"; type XaiVideoCreateResponse = { request_id?: string; @@ -54,6 +55,58 @@ type VideoGenerationSourceInput = { role?: string; }; +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +async function readXaiVideoJson(response: Response): Promise> { + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); + } + if (!isRecord(payload)) { + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); + } + return payload; +} + +function xaiErrorMessage(payload: Record): string | undefined { + const error = payload.error; + if (error === undefined || error === null) { + return undefined; + } + if (!isRecord(error)) { + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); + } + return normalizeOptionalString(error.message); +} + +function readXaiCreateResponse(payload: Record): XaiVideoCreateResponse { + return { + request_id: normalizeOptionalString(payload.request_id), + error: xaiErrorMessage(payload) ? { message: xaiErrorMessage(payload) } : null, + }; +} + +function readXaiStatusResponse(payload: Record): XaiVideoStatusResponse { + const status = normalizeOptionalString(payload.status); + if (!status || !["queued", "processing", "done", "failed", "expired"].includes(status)) { + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); + } + const video = payload.video; + if (video !== undefined && video !== null && !isRecord(video)) { + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); + } + return { + request_id: normalizeOptionalString(payload.request_id), + status: status as XaiVideoStatusResponse["status"], + video: isRecord(video) ? { url: normalizeOptionalString(video.url) } : null, + error: xaiErrorMessage(payload) ? { message: xaiErrorMessage(payload) } : null, + }; +} + function resolveXaiVideoBaseUrl(req: VideoGenerationRequest): string { return ( normalizeOptionalString(req.cfg?.models?.providers?.xai?.baseUrl) ?? DEFAULT_XAI_VIDEO_BASE_URL @@ -279,7 +332,7 @@ async function pollXaiVideo(params: { provider: "xai", requestFailedMessage: "xAI video status request failed", }); - const payload = (await response.json()) as XaiVideoStatusResponse; + const payload = readXaiStatusResponse(await readXaiVideoJson(response)); switch (payload.status) { case "done": return payload; @@ -403,7 +456,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { }); try { await assertOkOrThrowHttpError(response, "xAI video generation failed"); - const submitted = (await response.json()) as XaiVideoCreateResponse; + const submitted = readXaiCreateResponse(await readXaiVideoJson(response)); const requestId = normalizeOptionalString(submitted.request_id); if (!requestId) { throw new Error( @@ -423,7 +476,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { }); const videoUrl = normalizeOptionalString(completed.video?.url); if (!videoUrl) { - throw new Error("xAI video generation completed without an output URL"); + throw new Error(XAI_VIDEO_MALFORMED_RESPONSE); } const video = await downloadXaiVideo({ url: videoUrl,