fix(providers): harden video response schemas

This commit is contained in:
Vincent Koc
2026-05-16 09:53:12 +08:00
parent e01a885d18
commit 111b65a6fb
7 changed files with 544 additions and 44 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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<string, unknown> {
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) {

View File

@@ -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({

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
async function readOpenRouterVideoJson(response: Response): Promise<Record<string, unknown>> {
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<string, unknown>): 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,

View File

@@ -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: {

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
async function readXaiVideoJson(response: Response): Promise<Record<string, unknown>> {
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, unknown>): 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<string, unknown>): XaiVideoCreateResponse {
return {
request_id: normalizeOptionalString(payload.request_id),
error: xaiErrorMessage(payload) ? { message: xaiErrorMessage(payload) } : null,
};
}
function readXaiStatusResponse(payload: Record<string, unknown>): 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,