mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:34:46 +00:00
fix(providers): harden video response schemas
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user