From 78109c29e58eabbec3e79d7d5554c8d96ba69b13 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Jul 2026 09:26:46 +0800 Subject: [PATCH 01/10] feat(videos): add Grok video bridge for non-OpenAI models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the image bridge to support asynchronous video generation via xAI Grok Imagine Video. Video generation uses a submit→poll→download pattern (POST /v1/videos/generations → GET /v1/videos/{id}) with heartbeat forwarding to keep the SSE stream alive during the 30-180s generation window. New files: - src/images/xai-video-client.ts: submitVideoJob + pollVideoJob - src/images/fulfill-video.ts: arg parsing, async polling generator, result builder - tests/videos/: 32 tests (xai-video-client, fulfill-video, plan-video) - docs-site/.../video-bridge.md: user guide Modified files: - src/images/loop.ts: unified image+video fulfillment, per-turn call caps - src/images/types.ts: VideoBridgePlan/VideoCallResult shared types - src/images/synthetic-tool.ts: buildVideoTool, VIDEO_GEN_TOOL_NAME - src/images/plan.ts: planVideoBridge (opt-in via videoBridgeEnabled) - src/images/artifacts.ts: downloadVideoToArtifact (200MB cap, SSRF-protected) - src/server/responses/core.ts: video bridge wiring + tool dedup - src/types.ts: videoGeneration flag, video config fields Video bridge is opt-in (videoBridgeEnabled defaults to false). All upstream image-bridge hardening preserved (SSRF, size caps, abort linking, pinned HTTPS). --- .../src/content/docs/guides/video-bridge.md | 67 +++++++ src/images/artifacts.ts | 112 ++++++++++- src/images/fulfill-video.ts | 140 +++++++++++++ src/images/index.ts | 6 +- src/images/loop.ts | 187 +++++++++++++----- src/images/plan.ts | 46 ++++- src/images/synthetic-tool.ts | 45 +++++ src/images/types.ts | 15 ++ src/images/xai-video-client.ts | 158 +++++++++++++++ src/server/responses/core.ts | 40 ++-- src/types.ts | 10 + tests/images/z-handler-activation.test.ts | 2 +- tests/videos/fulfill-video.test.ts | 161 +++++++++++++++ tests/videos/plan-video.test.ts | 80 ++++++++ tests/videos/xai-video-client.test.ts | 132 +++++++++++++ 15 files changed, 1123 insertions(+), 78 deletions(-) create mode 100644 docs-site/src/content/docs/guides/video-bridge.md create mode 100644 src/images/fulfill-video.ts create mode 100644 src/images/xai-video-client.ts create mode 100644 tests/videos/fulfill-video.test.ts create mode 100644 tests/videos/plan-video.test.ts create mode 100644 tests/videos/xai-video-client.test.ts diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md new file mode 100644 index 0000000000..ba32a89109 --- /dev/null +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -0,0 +1,67 @@ +--- +title: Video Bridge +description: Generate videos with Grok Imagine Video through a non-OpenAI model. +--- + +## Overview + +The Video Bridge lets you use xAI's Grok Imagine Video generation through any non-OpenAI model +routed by opencodex. When enabled, a synthetic `video_gen` tool is injected into the conversation. +The model calls it like any function tool; opencodex intercepts the call, submits a video generation +job to xAI, polls until completion, and downloads the result. + +## Prerequisites + +- An xAI account with an API key (`ocx login xai` or set the key in your provider config) +- A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini) +- opencodex configured to route through the non-OpenAI provider + +## Configuration + +Add `videoBridgeEnabled: true` to your `images` config: + +```json +{ + "images": { + "bridgeEnabled": true, + "videoBridgeEnabled": true, + "videoBridgeModel": "grok-imagine-video", + "videoMaxRounds": 2, + "videoTimeoutMs": 300000 +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `videoBridgeEnabled` | `false` | Master switch. Must be explicitly enabled. | +| `videoBridgeModel` | `"grok-imagine-video"` | xAI video model id. | +| `videoMaxRounds` | `2` | Max video-gen rounds before forced final answer. | +| `videoTimeoutMs` | `300000` (5 min) | Per-video timeout including polling. | + +## How It Works + +1. opencodex detects a non-OpenAI routed model with `videoBridgeEnabled: true` +2. A synthetic `video_gen` function tool is injected into the conversation +3. When the model calls `video_gen`, opencodex submits a job to xAI's `/videos/generations` +4. The bridge polls the job status every 5-15 seconds, sending heartbeat messages to keep the stream alive +5. When the video is ready, it's downloaded to the artifacts directory +6. The local file path is returned to the model as a tool result + +## Supported Parameters + +The `video_gen` tool accepts: + +| Parameter | Type | Range | Description | +|-----------|------|-------|-------------| +| `prompt` | string | required | Detailed video generation prompt | +| `duration` | integer | 1-15 | Video length in seconds | +| `resolution` | string | `"480p"`, `"720p"` | Video resolution | +| `aspect_ratio` | string | 7 ratios | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` | + +## Limitations + +- **xAI only**: Video generation is only available through xAI's Grok Imagine Video API +- **Asynchronous**: Video generation takes 30-120 seconds +- **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p) +- **One video per call**: Each `video_gen` call produces one video +- **Coexists with Image Bridge**: Both bridges can be enabled simultaneously diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index d6c887e8a3..4c0c0bb413 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, open, unlink } from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import https from "node:https"; import type { RequestOptions } from "node:https"; @@ -475,3 +475,113 @@ export async function downloadImageToArtifact( // Retention is post-batch via pruneArtifacts (see fulfill.ts). return writeArtifactUnique(dir, "dl-", bytes, ext); } + +const MAX_VIDEO_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB + +export interface VideoBudget { + spent: number; +} + +export function createVideoBudget(): VideoBudget { + return { spent: 0 }; +} + +export function guessVideoExtFromMagic(bytes: Uint8Array): string { + const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); + // MP4/QuickTime/MOV: bytes 4-7 == "ftyp" (ISO BMFF) + if (sig.slice(4, 8) === "ftyp") return "mp4"; + // WebM/Matroska: \x1a\x45\xdf\xa3 + if (sig.startsWith("\x1a\x45\xdf\xa3")) return "webm"; + return "mp4"; +} + +/** + * Download a video from a URL to an artifact file with a 200 MiB hard cap, streaming the body + * to disk to avoid buffering. SSRF protection reuses the same destination policy + pinned HTTPS + * as image downloads. Format is sniffed from magic bytes. + */ +export async function downloadVideoToArtifact( + url: string, + budget?: VideoBudget, + signal?: AbortSignal, +): Promise { + // For data: URLs, handle inline (unlikely for video but keep parity) + if (url.startsWith("data:")) { + const commaIdx = url.indexOf(","); + const meta = url.slice(0, commaIdx); + const data = url.slice(commaIdx + 1); + const isBase64 = meta.includes(";base64"); + if (!isBase64) throw new Error("non-base64 data URI for video is not supported"); + const buf = Buffer.from(data, "base64"); + if (budget) budget.spent += buf.byteLength; + if (buf.byteLength > MAX_VIDEO_DOWNLOAD_BYTES) throw new Error("video data URI exceeds size cap"); + const ext = guessVideoExtFromMagic(buf); + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; + const dest = join(dir, name); + await writeFile(dest, buf, { mode: 0o600 }); + return dest; + } + + // SSRF protection: same validation as downloadImageToArtifact + let parsedUrl: URL; + try { parsedUrl = new URL(url); } catch { throw new Error("video URL is not valid"); } + if (parsedUrl.protocol !== "https:") { + throw new Error(`video URL must use HTTPS, got ${parsedUrl.protocol}`); + } + const assessment = assessUrlDestination(url); + if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { + throw new Error(`video URL targets ${assessment.detail}`); + } + const resolved = await resolvePublicAddresses(url); + const pinned = pickPinnedAddress(resolved.addresses); + const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES }); + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + throw new Error("video download failed: " + resp.status); + } + + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + const reader = resp.body?.getReader(); + if (!reader) throw new Error("video download returned no body"); + + // Peek the first chunk for magic-byte sniffing before opening the file. + const first = await reader.read(); + if (first.done || !first.value) { + throw new Error("video download returned empty body"); + } + const ext = guessVideoExtFromMagic(first.value); + const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; + const dest = join(dir, name); + const fh = await open(dest, "w", 0o600); + let totalBytes = first.value.byteLength; + if (budget) budget.spent += totalBytes; + if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { + reader.releaseLock(); + await fh.close(); + await unlink(dest).catch(() => {}); + throw new Error("video download exceeds size cap"); + } + let success = false; + try { + await fh.writeFile(first.value); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { + throw new Error("video download exceeds size cap"); + } + await fh.writeFile(value); + } + success = true; + } finally { + reader.releaseLock(); + await fh.close(); + if (!success) await unlink(dest).catch(() => {}); + } + return dest; +} diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts new file mode 100644 index 0000000000..ff4600f442 --- /dev/null +++ b/src/images/fulfill-video.ts @@ -0,0 +1,140 @@ +import type { VideoBridgePlan, VideoCallResult } from "./types"; +import { submitVideoJob, pollVideoJob } from "./xai-video-client"; +import { downloadVideoToArtifact, createVideoBudget, type VideoBudget } from "./artifacts"; + +/** Parsed arguments from the model's video_gen tool call. */ +export interface ParsedVideoArgs { + ok: true; + prompt: string; + duration?: number; + resolution?: string; + aspectRatio?: string; +} + +const INITIAL_POLL_INTERVAL_MS = 200; +const MAX_POLL_INTERVAL_MS = 15_000; +const POLL_BACKOFF = 1.5; +const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(new Error("aborted")); + return new Promise((resolve, reject) => { + const onAbort = (): void => { clearTimeout(timer); reject(new Error("aborted")); }; + const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Parse the JSON arguments string from a video_gen tool call. Mirrors fulfillImageCall's + * defensive parsing: invalid JSON or missing prompt returns an error result. + */ +export function parseVideoCallArgs(raw: string): + | ParsedVideoArgs + | { ok: false; error: string } { + let args: unknown; + try { + args = JSON.parse(raw || "{}"); + } catch { + return { ok: false, error: "invalid arguments JSON" }; + } + if (typeof args !== "object" || args === null) { + return { ok: false, error: "invalid arguments JSON" }; + } + const obj = args as Record; + + const prompt = + typeof obj.prompt === "string" ? obj.prompt : typeof obj.input === "string" ? obj.input : ""; + if (!prompt) { + return { ok: false, error: "missing prompt" }; + } + + const result: ParsedVideoArgs = { ok: true, prompt }; + + // duration: clamp to 1-15 + if (typeof obj.duration === "number") { + result.duration = Math.max(1, Math.min(15, Math.floor(obj.duration))); + } + + if (typeof obj.resolution === "string" && (obj.resolution === "480p" || obj.resolution === "720p")) { + result.resolution = obj.resolution; + } + if (typeof obj.aspect_ratio === "string") { + const VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"]; + if (VALID_RATIOS.includes(obj.aspect_ratio)) result.aspectRatio = obj.aspect_ratio; + } + + return result; +} + +/** + * Poll a video generation job, yielding heartbeats with elapsed-time messages so the + * caller can forward them to the SSE stream. Returns the final result when the job + * completes or fails/times out. + */ +export async function* pollVideoWithHeartbeats( + requestId: string, + auth: { baseUrl: string; token: string }, + signal: AbortSignal, + timeoutMs: number = DEFAULT_VIDEO_TIMEOUT_MS, +): AsyncGenerator< + { type: "heartbeat"; message: string }, + { ok: true; videoUrl: string } | { ok: false; error: string } +> { + const start = Date.now(); + let interval = INITIAL_POLL_INTERVAL_MS; + + for (;;) { + if (Date.now() - start >= timeoutMs) { + return { ok: false, error: `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` }; + } + + try { + const poll = await pollVideoJob(requestId, auth, signal); + if (poll.status === "done" && poll.videoUrl) { + return { ok: true, videoUrl: poll.videoUrl }; + } + if (poll.status === "failed") { + return { ok: false, error: "video generation failed" }; + } + if (poll.status === "expired") { + return { ok: false, error: "video generation expired" }; + } + // still processing — continue with backoff + } catch (e) { + // Transient poll errors (timeout, network) are tolerable — keep polling. + const msg = e instanceof Error ? e.message : String(e); + if (signal.aborted) return { ok: false, error: "client closed request during video generation" }; + console.warn(`[videos] poll error (will retry): ${msg}`); + } + + const elapsed = Math.floor((Date.now() - start) / 1000); + yield { type: "heartbeat", message: `Generating video... ${elapsed}s` }; + + try { + await sleep(interval, signal); + } catch { + return { ok: false, error: "client closed request during video generation" }; + } + + interval = Math.min(MAX_POLL_INTERVAL_MS, Math.floor(interval * POLL_BACKOFF)); + } +} + +/** + * Build a success VideoCallResult after the video has been downloaded to disk. + */ +export function buildVideoResult(path: string, prompt: string, model: string): VideoCallResult { + return { + ok: true, + model, + prompt, + path, + files: [path], + count: 1, + markdown: `[video](${path})`, + }; +} + +export type { VideoBudget }; +export { createVideoBudget }; diff --git a/src/images/index.ts b/src/images/index.ts index fc1f2bff11..f9f0fd5b99 100644 --- a/src/images/index.ts +++ b/src/images/index.ts @@ -1,4 +1,4 @@ -export { planImageBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; +export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop"; -export type { ImageBridgePlan, ImageCallResult } from "./types"; -export { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, isImageGenName } from "./synthetic-tool"; +export type { ImageBridgePlan, ImageCallResult, VideoBridgePlan, VideoCallResult } from "./types"; +export { buildImageTool, buildVideoTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isImageGenName, isVideoGenName } from "./synthetic-tool"; diff --git a/src/images/loop.ts b/src/images/loop.ts index e9240f74a5..1a4a08d480 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -1,11 +1,11 @@ /** - * Image bridge agentic loop — adapted from src/web-search/loop.ts but significantly simpler. + * Media bridge agentic loop — supports both image and video generation sidecars. * * The routed (non-OpenAI) model runs in a bounded loop. Each iteration is streamed and fully - * buffered internally. If the model calls an image-generation tool, the bridge fulfills it via - * the xAI sidecar, injects the result as a tool_result, and loops (bounded by maxRounds). When - * the model produces a real tool call or the budget is exhausted, the passthrough events are - * replayed to the bridge for final SSE output. + * buffered internally. If the model calls an image-generation or video-generation tool, the + * bridge fulfills it via the xAI sidecar, injects the result as a tool_result, and loops + * (bounded by maxRounds). When the model produces a real tool call or the budget is exhausted, + * the passthrough events are replayed to the bridge for final SSE output. * * Removed vs web-search: no sidecar backend selection, no forced-answer nudge, no failed-query * dedup, no describeImages/structuredOutput, no recordSidecarOutcome. @@ -20,9 +20,11 @@ import { readBoundedResponseBody } from "../lib/bounded-body"; import { fetchWithResetRetry } from "../lib/upstream-retry"; import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream"; import { fulfillImageCall } from "./fulfill"; -import { createImageBudget } from "./artifacts"; -import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; -import type { ImageBridgePlan } from "./types"; +import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video"; +import { submitVideoJob } from "./xai-video-client"; +import { downloadVideoToArtifact, createImageBudget, pruneArtifacts } from "./artifacts"; +import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool"; +import type { ImageBridgePlan, VideoBridgePlan } from "./types"; const SSE_HEADERS = { "Content-Type": "text/event-stream", @@ -36,8 +38,10 @@ const STALL_TIMEOUT_MS = 200_000; export const DEFAULT_MAX_ROUNDS = 3; /** Absolute ceiling so a hand-edited `images.maxRounds: 10000` cannot unbound paid xAI calls. */ export const MAX_ROUNDS_HARD_LIMIT = 10; -/** Cap paid xAI fulfillments per turn (parallel calls in one round count separately). */ +/** Cap paid xAI image fulfillments per turn (parallel calls in one round count separately). */ export const MAX_IMAGE_CALLS_PER_TURN = 10; +/** Cap paid xAI video fulfillments per turn (video is slower/costlier than image). */ +export const MAX_VIDEO_CALLS_PER_TURN = 3; /** * Clamp a configured maxRounds value to a safe integer in [0, MAX_ROUNDS_HARD_LIMIT]. @@ -48,23 +52,27 @@ export function clampImageMaxRounds(value: unknown): number { return Math.max(0, Math.min(MAX_ROUNDS_HARD_LIMIT, Math.floor(value))); } -/** Drop image-specific tool_choice when image tools are stripped for a forced-final pass. */ -function stripImageToolChoice( +/** Drop image/video-specific tool_choice when media tools are stripped for a forced-final pass. */ +function stripMediaToolChoice( options: OcxRequestOptions, - plan: ImageBridgePlan, + plan?: ImageBridgePlan, + videoPlan?: VideoBridgePlan, ): OcxRequestOptions { const tc = options.toolChoice; if (!tc || typeof tc !== "object") return options; + const isMediaTool = (name: string): boolean => + name === IMAGE_GEN_TOOL_NAME || + name === VIDEO_GEN_TOOL_NAME || + (plan?.toolNames.has(name) ?? false) || + (videoPlan?.toolNames.has(name) ?? false); if ("name" in tc && typeof tc.name === "string") { - if (tc.name === IMAGE_GEN_TOOL_NAME || plan.toolNames.has(tc.name)) { + if (isMediaTool(tc.name)) { return { ...options, toolChoice: "auto" }; } return options; } if ("allowedTools" in tc && Array.isArray(tc.allowedTools)) { - const filtered = tc.allowedTools.filter( - (name) => name !== IMAGE_GEN_TOOL_NAME && !plan.toolNames.has(name), - ); + const filtered = tc.allowedTools.filter(name => !isMediaTool(name)); if (filtered.length === tc.allowedTools.length) return options; if (filtered.length === 0) return { ...options, toolChoice: "auto" }; return { ...options, toolChoice: { ...tc, allowedTools: filtered } }; @@ -190,7 +198,10 @@ class LoopError extends Error { export interface ImageBridgeDeps { parsed: OcxParsedRequest; adapter: ProviderAdapter; - plan: ImageBridgePlan; + plan?: ImageBridgePlan; + videoPlan?: VideoBridgePlan; + /** Per-video generation timeout (ms) including polling. */ + videoTimeoutMs?: number; /** Headers forwarded from the original request (e.g. Codex auth). Cloned per iteration. */ forwardHeaders?: Headers; /** Called before each routed-model dispatch in the bridge loop, for attempt telemetry. */ @@ -227,7 +238,7 @@ export interface ImageBridgeDeps { * inject the answer as a tool_result, and loop (bounded by `maxRounds`). */ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { - const { parsed, plan, abortSignal } = deps; + const { parsed, plan, videoPlan, videoTimeoutMs, abortSignal } = deps; let adapter = deps.adapter; const maxRounds = clampImageMaxRounds(deps.maxRounds ?? DEFAULT_MAX_ROUNDS); const HARD_CAP = maxRounds + 1; @@ -239,6 +250,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { @@ -275,17 +287,24 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + // Merge tool names from both plans for event scanning. + const mediaToolNames = new Set(); + if (plan) for (const n of plan.toolNames) mediaToolNames.add(n); + if (videoPlan) for (const n of videoPlan.toolNames) mediaToolNames.add(n); + // Forced-final must strip every image/video-generation alias the plans know about — not only + // tools flagged `imageGeneration:true` or `videoGeneration:true`. Hosted `image_generation` / + // function aliases would otherwise remain callable; scanEventsForImageCall would strip the + // call while forceFinal blocks fulfillment, leaving the client an empty completion. + const toolsNoMedia = allTools.filter(t => { if (t.imageGeneration) return false; - if (plan.toolNames.has(t.name)) return false; - if (t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + if (t.videoGeneration) return false; + if (plan && plan.toolNames.has(t.name)) return false; + if (plan && t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + if (videoPlan && videoPlan.toolNames.has(t.name)) return false; return true; }); const budget = createImageBudget(); + const vBudget = createVideoBudget(); // Link an internal AbortController to the turn signal so a client cancel of the SSE body aborts // in-flight model fetches AND the sidecar. @@ -310,8 +329,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise>; args: Record }> = []; for (const call of split.calls) { - yield { type: "heartbeat" }; - let result: Awaited>; - if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { - result = { - ok: false, - model: plan.model, - prompt: "", - files: [], - count: 0, - error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`, - }; + const isVideoCall = videoPlan?.toolNames.has(call.name) === true; + if (isVideoCall) { + yield { type: "heartbeat" }; + if (paidVideoCalls >= MAX_VIDEO_CALLS_PER_TURN) { + const vResult = { + ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0, + error: `video call budget exhausted (max ${MAX_VIDEO_CALLS_PER_TURN} per turn)`, + }; + let pArgs: Record = {}; + try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) pArgs = raw as Record; } catch { /* malformed args */ } + fulfilled.push({ call, result: vResult, args: pArgs }); + continue; + } + paidVideoCalls += 1; + const vArgs = parseVideoCallArgs(call.args); + let vResult; + if (!vArgs.ok) { + vResult = { ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0, error: vArgs.error }; + } else { + try { + const { requestId } = await submitVideoJob( + { + prompt: vArgs.prompt, model: videoPlan!.model, + ...(vArgs.duration != null ? { duration: vArgs.duration } : {}), + ...(vArgs.resolution != null ? { resolution: vArgs.resolution } : {}), + ...(vArgs.aspectRatio != null ? { aspectRatio: vArgs.aspectRatio } : {}), + }, + videoPlan!.auth, signal, + ); + const pollGen = pollVideoWithHeartbeats(requestId, videoPlan!.auth, signal, videoTimeoutMs); + let pollResult: { ok: true; videoUrl: string } | { ok: false; error: string }; + try { + for (;;) { + const { value, done } = await pollGen.next(); + if (done) { pollResult = value; break; } + yield { type: "heartbeat" }; + } + } finally { + await pollGen.return({ ok: false, error: "cancelled" }).catch(() => {}); + } + if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge"); + if (pollResult.ok) { + const dlPath = await downloadVideoToArtifact(pollResult.videoUrl, vBudget, signal); + pruneArtifacts(videoPlan?.artifactsKeepCount); + vResult = buildVideoResult(dlPath, vArgs.prompt, videoPlan!.model); + } else { + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt, files: [], count: 0, error: pollResult.error }; + } + } catch (e) { + if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge"); + const error = e instanceof Error ? e.message : String(e); + vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt ?? "", files: [], count: 0, error }; + } + } + if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge"); + let vParsedArgs: Record = {}; + try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) vParsedArgs = raw as Record; } catch { /* malformed args */ } + fulfilled.push({ call, result: vResult, args: vParsedArgs }); } else { - paidImageCalls += 1; - result = await fulfillImageCall( - { id: call.id, name: call.name, arguments: call.args }, - plan, budget, signal, - ); - } - if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); - let parsedArgs: Record = {}; - try { - const raw: unknown = JSON.parse(call.args || "{}"); - if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { - parsedArgs = raw as Record; + yield { type: "heartbeat" }; + let result: Awaited>; + if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { + result = { + ok: false, + model: plan!.model, + prompt: "", + files: [], + count: 0, + error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`, + }; + } else { + paidImageCalls += 1; + result = await fulfillImageCall( + { id: call.id, name: call.name, arguments: call.args }, + plan!, budget, signal, + ); } - } catch { /* malformed args */ } - fulfilled.push({ call, result, args: parsedArgs }); + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + let parsedArgs: Record = {}; + try { + const raw: unknown = JSON.parse(call.args || "{}"); + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + parsedArgs = raw as Record; + } + } catch { /* malformed args */ } + fulfilled.push({ call, result, args: parsedArgs }); + } } const now = Date.now(); messages.push({ diff --git a/src/images/plan.ts b/src/images/plan.ts index ce59a67e57..cf5aa28933 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -1,8 +1,8 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; -import type { ImageBridgePlan } from "./types"; +import type { ImageBridgePlan, VideoBridgePlan } from "./types"; import { resolveEnvValue } from "../config"; import { getProviderRegistryEntry } from "../providers/registry"; -import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; +import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool"; const DEFAULT_MODEL = "grok-imagine-image-quality"; /** Absolute ceiling for `images.timeoutMs` (matches /v1/images relay budget). */ @@ -78,3 +78,45 @@ export async function planImageBridge( ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}), }; } + +const DEFAULT_VIDEO_MODEL = "grok-imagine-video"; + +/** + * Decide whether the video bridge should activate for this request. Unlike images, video + * generation has no hosted OpenAI tool type — the synthetic `video_gen` tool is unconditionally + * injected when `videoBridgeEnabled` is true. The bridge activates only when: + * 1. videoBridgeEnabled is explicitly true (opt-in) + * 2. the routed provider is NOT api.openai.com (native passthrough) + * 3. an xAI provider with a valid API key is available + */ +export async function planVideoBridge( + config: OcxConfig, + _parsed: OcxParsedRequest, + routedProvider: OcxProviderConfig, +): Promise { + if (config.images?.videoBridgeEnabled !== true) return undefined; + // Don't intercept for OpenAI native passthrough + const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); + if (host === "api.openai.com") return undefined; + const found = findXaiProvider(config); + if (!found) return undefined; + const token = resolveXaiImageApiKey(found.provider); + if (!token) return undefined; + // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. + const registryEntry = getProviderRegistryEntry("xai"); + const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); + const toolNames = new Set(); + toolNames.add(VIDEO_GEN_TOOL_NAME); + const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs); + const keepRaw = config.images?.artifactsKeepCount; + const artifactsKeepCount = + typeof keepRaw === "number" && Number.isFinite(keepRaw) ? Math.floor(keepRaw) : undefined; + return { + provider: found.provider, + auth: { baseUrl: pinnedBaseUrl, token }, + model: config.images?.videoBridgeModel ?? DEFAULT_VIDEO_MODEL, + toolNames, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}), + }; +} diff --git a/src/images/synthetic-tool.ts b/src/images/synthetic-tool.ts index 8ba1fbda47..84ac536ab3 100644 --- a/src/images/synthetic-tool.ts +++ b/src/images/synthetic-tool.ts @@ -86,3 +86,48 @@ export function buildImageTool(): OcxTool { imageGeneration: true, }; } + +/** The function name the chat model sees for video generation. */ +export const VIDEO_GEN_TOOL_NAME = "video_gen"; + +const VIDEO_GEN_NAMES = new Set([ + "video_gen", + "video_generation", + "videogen", + "generate_video", + "generatevideo", +]); + +export function isVideoGenName(name: string): boolean { + return VIDEO_GEN_NAMES.has(name.toLowerCase()); +} + +/** + * The synthetic function tool exposed to a chat/anthropic model for video generation. + * Unlike image_generation, there is no hosted OpenAI tool type for video — this tool is + * unconditionally injected when the video bridge is enabled. `videoGeneration:true` flags it + * so the forced-final pass can drop it. + */ +export function buildVideoTool(): OcxTool { + return { + name: VIDEO_GEN_TOOL_NAME, + description: + "Generate a short video (1-15 seconds) from a text prompt. Returns an absolute local filesystem path. " + + "Use when the user asks to create, animate, or generate a video.", + parameters: { + type: "object", + properties: { + prompt: { type: "string", description: "Detailed video generation prompt. Required." }, + duration: { type: "integer", minimum: 1, maximum: 15, description: "Video length in seconds. Default 6." }, + resolution: { type: "string", enum: ["480p", "720p"], description: "Video resolution. Default 720p." }, + aspect_ratio: { + type: "string", + enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"], + description: "Aspect ratio. Default 16:9.", + }, + }, + required: ["prompt"], + }, + videoGeneration: true, + }; +} diff --git a/src/images/types.ts b/src/images/types.ts index e721777467..d9176aacbc 100644 --- a/src/images/types.ts +++ b/src/images/types.ts @@ -24,3 +24,18 @@ export interface ImageCallResult { markdown?: string; error?: string; } + +/** Plan for the video bridge. Same auth/provider shape as image, without image-specific defaults. */ +export interface VideoBridgePlan { + provider: OcxProviderConfig; + auth: { baseUrl: string; token: string }; + model: string; + toolNames: Set; + /** Per-call xAI deadline (ms) for submit + poll. */ + timeoutMs?: number; + /** Max artifact files to retain (from config.images.artifactsKeepCount). Default 200. ≤0 disables prune. */ + artifactsKeepCount?: number; +} + +/** Result shape for video fulfillment — same as image. */ +export type VideoCallResult = ImageCallResult; diff --git a/src/images/xai-video-client.ts b/src/images/xai-video-client.ts new file mode 100644 index 0000000000..d45596970a --- /dev/null +++ b/src/images/xai-video-client.ts @@ -0,0 +1,158 @@ +/** + * xAI video generation client. + * + * Video generation is asynchronous: a POST to `/videos/generations` returns a `request_id`, + * which is then polled via GET `/videos/{request_id}` until the status is terminal. + * The submit call uses a 60 s timeout (it only returns an ID); the poll call uses 30 s. + * Non-2xx responses throw with the original status code so callers can distinguish + * rate-limit / auth failures from transient errors. + */ + +export interface XaiVideoSubmitRequest { + prompt: string; + model?: string; // default "grok-imagine-video" + duration?: number; // 1-15 seconds + resolution?: string; // "480p" | "720p" + aspectRatio?: string; // "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" +} + +export interface XaiVideoSubmitResult { + requestId: string; +} + +export interface XaiVideoPollResult { + status: "processing" | "done" | "failed" | "expired"; + videoUrl?: string; + progress?: number; +} + +const SUBMIT_TIMEOUT_MS = 60_000; +const POLL_TIMEOUT_MS = 30_000; +const DEFAULT_VIDEO_MODEL = "grok-imagine-video"; + +/** Maximum response body size for the submit and poll calls (responses are small JSON). */ +const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MiB — these endpoints return small JSON + +/** + * Read an HTTP response body as text with a hard byte cap. Used for the non-streaming + * video submit/poll endpoints. + */ +async function readBoundedText(resp: Response): Promise { + const reader = resp.body?.getReader(); + if (!reader) throw new Error("xAI video API returned no body"); + const decoder = new TextDecoder(); + let text = ""; + let totalBytes = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_RESPONSE_BYTES) throw new Error("xAI video API response exceeds size cap"); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + reader.releaseLock(); + } + return text; +} + +/** + * Submit a video generation job to xAI. Returns the `request_id` used for polling. + */ +export async function submitVideoJob( + req: XaiVideoSubmitRequest, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +): Promise { + const body: Record = { + model: req.model ?? DEFAULT_VIDEO_MODEL, + prompt: req.prompt, + }; + if (typeof req.duration === "number") body.duration = req.duration; + if (typeof req.resolution === "string") body.resolution = req.resolution; + if (typeof req.aspectRatio === "string") body.aspect_ratio = req.aspectRatio; + + const timeout = AbortSignal.timeout(SUBMIT_TIMEOUT_MS); + const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const resp = await fetch(`${auth.baseUrl}/videos/generations`, { + method: "POST", + headers: { + "Authorization": `Bearer ${auth.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: linkedSignal, + }); + + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + throw new Error("xAI videos API returned " + resp.status); + } + + const text = await readBoundedText(resp); + const json = JSON.parse(text) as { request_id?: string; id?: string }; + const requestId = json.request_id ?? json.id; + if (typeof requestId !== "string") { + throw new Error("xAI videos API did not return a request_id"); + } + return { requestId }; +} + +/** + * Poll the status of a video generation job. Call this repeatedly with backoff until + * `status` is `"done"` (video ready) or a terminal failure state. + */ +export async function pollVideoJob( + requestId: string, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +): Promise { + const timeout = AbortSignal.timeout(POLL_TIMEOUT_MS); + const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const resp = await fetch(`${auth.baseUrl}/videos/${requestId}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${auth.token}`, + }, + signal: linkedSignal, + }); + + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + throw new Error("xAI videos poll API returned " + resp.status); + } + + const text = await readBoundedText(resp); + const json = JSON.parse(text) as { + status?: string; + state?: string; + video?: { url?: string }; + videos?: Array<{ url?: string }>; + progress?: number; + }; + + // Normalize status — xAI uses "done"/"processing"/"failed"/"expired" but be lenient. + const rawStatus = (json.status ?? json.state ?? "").toLowerCase(); + let status: XaiVideoPollResult["status"]; + if (rawStatus === "done" || rawStatus === "completed" || rawStatus === "succeeded") { + status = "done"; + } else if (rawStatus === "failed" || rawStatus === "error") { + status = "failed"; + } else if (rawStatus === "expired" || rawStatus === "timeout") { + status = "expired"; + } else { + status = "processing"; + } + + const videoUrl = json.video?.url ?? json.videos?.[0]?.url; + + return { + status, + ...(typeof videoUrl === "string" ? { videoUrl } : {}), + ...(typeof json.progress === "number" ? { progress: json.progress } : {}), + }; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7ac73b1a95..5e8f31ab50 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -52,7 +52,7 @@ import { rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; -import { buildImageTool, planImageBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME } from "../../images"; +import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; import { @@ -1597,48 +1597,54 @@ export async function handleResponses( ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar) : undefined; const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; + const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; - if (imgPlan && (!wsPlan || adapter.runTurn)) { + if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { // The bridge forces stream:true internally and returns SSE. Non-streaming requests can't be // served — reject explicitly rather than returning SSE to a client expecting JSON. if (!parsed.stream) { - return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); + return formatErrorResponse(400, "invalid_request_error", "media bridge requires stream=true"); } - // Replace any pre-existing image_gen alias instead of appending a duplicate wire name. + // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names. const priorTools = parsed.context.tools ?? []; - parsed.context.tools = [ - ...priorTools.filter(t => { - if (t.imageGeneration) return false; - if (imgPlan.toolNames.has(t.name)) return false; - if (t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; - return true; - }), - buildImageTool(), - ]; + const bridgeTools = [...priorTools.filter(t => { + if (t.imageGeneration) return false; + if (t.videoGeneration) return false; + if (imgPlan && imgPlan.toolNames.has(t.name)) return false; + if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + if (vidPlan && vidPlan.toolNames.has(t.name)) return false; + return true; + })]; + const existingNames = new Set(bridgeTools.map(t => t.name)); + if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool()); + if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool()); + parsed.context.tools = bridgeTools; // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name. const tc = parsed.options.toolChoice; if (tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { const mapped = tc.allowedTools.map(name => - name === "image_generation" || name === "image_gen" || imgPlan.toolNames.has(name) + name === "image_generation" || name === "image_gen" || (imgPlan?.toolNames.has(name) ?? false) ? IMAGE_GEN_TOOL_NAME : name, ); parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; } else if (tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" - && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { + && (tc.name === "image_generation" || (imgPlan?.toolNames.has(tc.name) ?? false))) { parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; } const imgResponse = await runWithImageBridge({ parsed, adapter, - plan: imgPlan, + ...(imgPlan ? { plan: imgPlan } : {}), + ...(vidPlan ? { videoPlan: vidPlan } : {}), forwardHeaders: selectedForwardHeaders, onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens), abortSignal: options.abortSignal, - maxRounds: clampImageMaxRounds(config.images?.maxRounds), + maxRounds: imgPlan ? clampImageMaxRounds(config.images?.maxRounds) : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, fetchImpl: providerFetch(route.provider), onRequestBuilt: request => recordAdapterReasoning(logCtx, request), + ...(config.images?.videoTimeoutMs ? { videoTimeoutMs: config.images.videoTimeoutMs } : {}), onUsage: usage => { // Cursor may assign _cursorConversationId inside the image loop's first runTurn; // backfill so Logs can filter/total that opening request (parity with the normal diff --git a/src/types.ts b/src/types.ts index 622e4ec161..fe27060ebb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -160,6 +160,8 @@ export interface OcxTool { webSearch?: boolean; /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ imageGeneration?: boolean; + /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ + videoGeneration?: boolean; } /** @@ -772,6 +774,14 @@ export interface OcxImagesConfig { maxRounds?: number; /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */ artifactsKeepCount?: number; + /** Master switch for the video bridge. Default false — must be explicitly opted in. */ + videoBridgeEnabled?: boolean; + /** Model for xAI video generation. Default "grok-imagine-video". */ + videoBridgeModel?: string; + /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */ + videoMaxRounds?: number; + /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */ + videoTimeoutMs?: number; } export interface OcxSearchConfig { diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index 324b877328..26f21ef657 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -149,7 +149,7 @@ describe("image bridge dispatch priority (handler activation)", () => { const res = await post(false, [{ type: "image_generation" }]); expect(res.status).toBe(400); expect(imageBridgeRun).toBe(false); - expect((await res.text())).toContain("image bridge requires stream=true"); + expect((await res.text())).toContain("media bridge requires stream=true"); }); test("dual-tool (image_generation + web_search), both eligible → web-search wins, image bridge deferred", async () => { diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts new file mode 100644 index 0000000000..d9ad75e226 --- /dev/null +++ b/tests/videos/fulfill-video.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult } from "../../src/images/fulfill-video"; +import { pollVideoJob } from "../../src/images/xai-video-client"; + +describe("parseVideoCallArgs", () => { + test("parses valid args", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "hello", duration: 5, resolution: "720p", aspect_ratio: "16:9" })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prompt).toBe("hello"); + expect(result.duration).toBe(5); + expect(result.resolution).toBe("720p"); + expect(result.aspectRatio).toBe("16:9"); + } + }); + + test("accepts input as alias for prompt", () => { + const result = parseVideoCallArgs(JSON.stringify({ input: "world" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.prompt).toBe("world"); + }); + + test("fails on missing prompt", () => { + const result = parseVideoCallArgs(JSON.stringify({ duration: 5 })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("missing prompt"); + }); + + test("fails on invalid JSON", () => { + const result = parseVideoCallArgs("not json"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid arguments JSON"); + }); + + test("fails on null", () => { + const result = parseVideoCallArgs("null"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invalid arguments JSON"); + }); + + test("clamps duration to 1-15", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 100 })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.duration).toBe(15); + }); + + test("clamps duration minimum to 1", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 0 })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.duration).toBe(1); + }); + + test("rejects invalid resolution", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", resolution: "1080p" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.resolution).toBeUndefined(); + }); + + test("rejects invalid aspect_ratio", () => { + const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", aspect_ratio: "5:4" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.aspectRatio).toBeUndefined(); + }); +}); + +describe("pollVideoWithHeartbeats", () => { + beforeEach(() => { + mock.restore(); + }); + + test("returns done on first poll", async () => { + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => Promise.resolve({ + status: "done" as const, + videoUrl: "https://cdn.x.ai/v.mp4", + })), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); + const heartbeats: string[] = []; + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + heartbeats.push(value.message); + } + expect(result.ok).toBe(true); + if (result.ok) expect(result.videoUrl).toBe("https://cdn.x.ai/v.mp4"); + }); + + test("yields at least one heartbeat before returning", async () => { + let callCount = 0; + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => { + callCount++; + return Promise.resolve({ + status: callCount >= 2 ? "done" as const : "processing" as const, + ...(callCount >= 2 ? { videoUrl: "https://x.ai/v.mp4" } : {}), + }); + }), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal, 60_000); + const heartbeats: string[] = []; + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + heartbeats.push(value.message); + } + expect(heartbeats.length).toBeGreaterThanOrEqual(1); + expect(result.ok).toBe(true); + }); + + test("returns failed status", async () => { + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => Promise.resolve({ status: "failed" as const })), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + } + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("video generation failed"); + }); + + test("returns timeout error when timeoutMs exceeded", async () => { + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => Promise.resolve({ status: "processing" as const })), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal, 0); + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + } + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("timed out"); + }); +}); + +describe("buildVideoResult", () => { + test("builds success result", () => { + const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video"); + expect(result.ok).toBe(true); + expect(result.path).toBe("/tmp/vid-123.mp4"); + expect(result.prompt).toBe("dance"); + expect(result.model).toBe("grok-imagine-video"); + expect(result.files).toEqual(["/tmp/vid-123.mp4"]); + expect(result.count).toBe(1); + expect(result.markdown).toContain("vid-123.mp4"); + }); +}); diff --git a/tests/videos/plan-video.test.ts b/tests/videos/plan-video.test.ts new file mode 100644 index 0000000000..75faf28ee6 --- /dev/null +++ b/tests/videos/plan-video.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { planVideoBridge } from "../../src/images/plan"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { VIDEO_GEN_TOOL_NAME } from "../../src/images/synthetic-tool"; + +function makeConfig(overrides: Partial = {}): OcxConfig { + const xai: OcxProviderConfig = { + name: "xai", + baseUrl: "https://api.x.ai/v1", + authMode: "api_key", + apiKey: "xai-test-key", + }; + return { + providers: { xai }, + ...overrides, + } as unknown as OcxConfig; +} + +function makeParsed(): OcxParsedRequest { + return { stream: true, context: { messages: [] } } as unknown as OcxParsedRequest; +} + +function makeProvider(host: string): OcxProviderConfig { + return { baseUrl: `https://${host}`, authMode: "api_key", apiKey: "other-key" } as unknown as OcxProviderConfig; +} + +describe("planVideoBridge", () => { + test("returns undefined when videoBridgeEnabled is not true", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: false } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when videoBridgeEnabled is missing", async () => { + const config = makeConfig({ images: {} } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns plan when enabled with valid xAI provider", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeDefined(); + expect(plan!.model).toBe("grok-imagine-video"); + expect(plan!.auth.token).toBe("xai-test-key"); + expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1"); + expect(plan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(true); + }); + + test("returns undefined for OpenAI native passthrough", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.openai.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when no xAI provider available", async () => { + const config: OcxConfig = { + providers: { anthropic: makeProvider("api.anthropic.com") }, + images: { videoBridgeEnabled: true }, + } as unknown as OcxConfig; + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("returns undefined when xAI provider uses oauth (no API key)", async () => { + const config: OcxConfig = { + providers: { xai: { baseUrl: "https://api.x.ai/v1", authMode: "oauth", apiKey: undefined } }, + images: { videoBridgeEnabled: true }, + } as unknown as OcxConfig; + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeUndefined(); + }); + + test("respects custom videoBridgeModel", async () => { + const config = makeConfig({ images: { videoBridgeEnabled: true, videoBridgeModel: "custom-video-model" } } as unknown as OcxConfig); + const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com")); + expect(plan).toBeDefined(); + expect(plan!.model).toBe("custom-video-model"); + }); +}); diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts new file mode 100644 index 0000000000..4133d46267 --- /dev/null +++ b/tests/videos/xai-video-client.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { submitVideoJob, pollVideoJob } from "../../src/images/xai-video-client"; + +const auth = { baseUrl: "https://api.x.ai/v1", token: "test-key" }; + +function mockFetchResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("submitVideoJob", () => { + beforeEach(() => { + mock.restore(); + }); + + test("returns request_id from response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await submitVideoJob({ prompt: "a cat playing piano" }, auth); + expect(result.requestId).toBe("vid-123"); + }); + + test("accepts id field as fallback", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ id: "vid-456" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await submitVideoJob({ prompt: "sunset" }, auth); + expect(result.requestId).toBe("vid-456"); + }); + + test("sends correct POST body", async () => { + let capturedBody: string | undefined; + const fetchMock = mock((url: string, init: RequestInit) => { + capturedBody = init.body as string; + return Promise.resolve(mockFetchResponse({ request_id: "r1" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await submitVideoJob( + { prompt: "dance", model: "grok-imagine-video", duration: 5, resolution: "720p", aspectRatio: "16:9" }, + auth, + ); + + const body = JSON.parse(capturedBody!); + expect(body.prompt).toBe("dance"); + expect(body.model).toBe("grok-imagine-video"); + expect(body.duration).toBe(5); + expect(body.resolution).toBe("720p"); + expect(body.aspect_ratio).toBe("16:9"); + }); + + test("throws on non-2xx response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ error: "rate limited" }, 429))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("429"); + }); + + test("throws when request_id is missing", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ foo: "bar" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("request_id"); + }); +}); + +describe("pollVideoJob", () => { + beforeEach(() => { + mock.restore(); + }); + + test("returns done status with video URL", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ + status: "done", + video: { url: "https://cdn.x.ai/video.mp4" }, + }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("done"); + expect(result.videoUrl).toBe("https://cdn.x.ai/video.mp4"); + }); + + test("normalizes completed → done", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "completed" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("done"); + }); + + test("normalizes error → failed", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ state: "error" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("failed"); + }); + + test("returns processing for unknown status", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "rendering" }))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + const result = await pollVideoJob("vid-123", auth); + expect(result.status).toBe("processing"); + }); + + test("throws on non-2xx response", async () => { + const fetchMock = mock(() => Promise.resolve(mockFetchResponse({}, 401))); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await expect(pollVideoJob("vid-123", auth)).rejects.toThrow("401"); + }); + + test("uses GET method on poll URL", async () => { + let capturedUrl: string | undefined; + let capturedMethod: string | undefined; + const fetchMock = mock((url: string, init: RequestInit) => { + capturedUrl = url; + capturedMethod = init.method; + return Promise.resolve(mockFetchResponse({ status: "processing" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await pollVideoJob("vid-789", auth); + expect(capturedUrl).toContain("/videos/vid-789"); + expect(capturedMethod).toBe("GET"); + }); +}); From 47225638838fa235da7f3163e85ba09f9d1f909b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Jul 2026 15:26:04 +0800 Subject: [PATCH 02/10] fix(videos): address Codex P1/P2 + CodeRabbit review findings P1 fixes: - Non-streaming requests no longer 400 when video bridge is enabled (only image bridge requires stream=true; video-only skips bridge for non-stream requests) - Restore globalThis.fetch after video client tests (afterEach cleanup) P2 fixes: - Use clamped vidPlan.timeoutMs instead of raw config value - Honor videoMaxRounds when both image+video bridges active (tighter wins) - guessVideoExtFromMagic throws on unrecognized magic (was defaulting to mp4) - Poll 4xx permanent failures (except 429) fail fast instead of retrying - buildVideoResult uses pathToFileURL for cross-platform markdown links - Initial poll interval restored to 5s (was 200ms) - done-without-videoUrl returns error instead of spinning until timeout - VideoBudget charges every streamed chunk (was only first chunk) - downloadVideoToArtifact: reader cleanup on file-open failure - readBoundedText: cancel reader body on size cap breach - Namespaced video_gen tools preserved in dedup filter - Fix JSON missing closing brace in docs - Clarify API key auth requirement in docs prerequisites --- .../src/content/docs/guides/video-bridge.md | 3 +- src/images/artifacts.ts | 54 +++++++++++-------- src/images/fulfill-video.ts | 20 ++++--- src/images/xai-video-client.ts | 9 +++- src/server/responses/core.ts | 23 +++++--- tests/images/z-handler-activation.test.ts | 2 +- tests/videos/fulfill-video.test.ts | 7 ++- tests/videos/xai-video-client.test.ts | 16 +++--- 8 files changed, 84 insertions(+), 50 deletions(-) diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md index ba32a89109..d2609e93e3 100644 --- a/docs-site/src/content/docs/guides/video-bridge.md +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -12,7 +12,7 @@ job to xAI, polls until completion, and downloads the result. ## Prerequisites -- An xAI account with an API key (`ocx login xai` or set the key in your provider config) +- An xAI account with an API key (set `XAI_API_KEY` or configure the key in your provider config — `ocx login xai` alone is not sufficient for the video bridge, which requires API key auth) - A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini) - opencodex configured to route through the non-OpenAI provider @@ -28,6 +28,7 @@ Add `videoBridgeEnabled: true` to your `images` config: "videoBridgeModel": "grok-imagine-video", "videoMaxRounds": 2, "videoTimeoutMs": 300000 + } } ``` diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 4c0c0bb413..32d65821ff 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -487,12 +487,13 @@ export function createVideoBudget(): VideoBudget { } export function guessVideoExtFromMagic(bytes: Uint8Array): string { + if (bytes.byteLength < 12) throw new Error("video data too short for magic byte sniffing"); const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); // MP4/QuickTime/MOV: bytes 4-7 == "ftyp" (ISO BMFF) if (sig.slice(4, 8) === "ftyp") return "mp4"; // WebM/Matroska: \x1a\x45\xdf\xa3 if (sig.startsWith("\x1a\x45\xdf\xa3")) return "webm"; - return "mp4"; + throw new Error("unrecognized video format — magic bytes do not match MP4 or WebM"); } /** @@ -549,39 +550,46 @@ export async function downloadVideoToArtifact( if (!reader) throw new Error("video download returned no body"); // Peek the first chunk for magic-byte sniffing before opening the file. - const first = await reader.read(); - if (first.done || !first.value) { - throw new Error("video download returned empty body"); - } - const ext = guessVideoExtFromMagic(first.value); - const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; - const dest = join(dir, name); - const fh = await open(dest, "w", 0o600); - let totalBytes = first.value.byteLength; - if (budget) budget.spent += totalBytes; - if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { - reader.releaseLock(); - await fh.close(); - await unlink(dest).catch(() => {}); - throw new Error("video download exceeds size cap"); - } - let success = false; + // If anything fails between acquiring the reader and opening the file, cancel + release the reader. + let dest: string | undefined; + let fh: { close(): Promise; writeFile(data: Uint8Array): Promise } | undefined; try { + const first = await reader.read(); + if (first.done || !first.value) { + throw new Error("video download returned empty body"); + } + const ext = guessVideoExtFromMagic(first.value); + const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; + dest = join(dir, name); + fh = await open(dest, "w", 0o600); + // Write first chunk and set up accounting + let totalBytes = first.value.byteLength; + if (budget) budget.spent += totalBytes; + if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { + throw new Error("video download exceeds size cap"); + } await fh.writeFile(first.value); for (;;) { const { value, done } = await reader.read(); if (done) break; totalBytes += value.byteLength; + if (budget) budget.spent += value.byteLength; if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { throw new Error("video download exceeds size cap"); } await fh.writeFile(value); } - success = true; - } finally { - reader.releaseLock(); await fh.close(); - if (!success) await unlink(dest).catch(() => {}); + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + return dest; + } catch (err) { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + if (fh) { + try { await fh.close(); } catch { /* ignore */ } + } + if (dest) await unlink(dest).catch(() => {}); + throw err; } - return dest; } diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts index ff4600f442..1955f18360 100644 --- a/src/images/fulfill-video.ts +++ b/src/images/fulfill-video.ts @@ -1,3 +1,4 @@ +import { pathToFileURL } from "node:url"; import type { VideoBridgePlan, VideoCallResult } from "./types"; import { submitVideoJob, pollVideoJob } from "./xai-video-client"; import { downloadVideoToArtifact, createVideoBudget, type VideoBudget } from "./artifacts"; @@ -11,7 +12,7 @@ export interface ParsedVideoArgs { aspectRatio?: string; } -const INITIAL_POLL_INTERVAL_MS = 200; +const INITIAL_POLL_INTERVAL_MS = 5_000; const MAX_POLL_INTERVAL_MS = 15_000; const POLL_BACKOFF = 1.5; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min @@ -91,8 +92,11 @@ export async function* pollVideoWithHeartbeats( try { const poll = await pollVideoJob(requestId, auth, signal); - if (poll.status === "done" && poll.videoUrl) { - return { ok: true, videoUrl: poll.videoUrl }; + if (poll.status === "done") { + if (poll.videoUrl) { + return { ok: true, videoUrl: poll.videoUrl }; + } + return { ok: false, error: "video generation completed but no video URL was returned" }; } if (poll.status === "failed") { return { ok: false, error: "video generation failed" }; @@ -102,10 +106,12 @@ export async function* pollVideoWithHeartbeats( } // still processing — continue with backoff } catch (e) { - // Transient poll errors (timeout, network) are tolerable — keep polling. - const msg = e instanceof Error ? e.message : String(e); if (signal.aborted) return { ok: false, error: "client closed request during video generation" }; - console.warn(`[videos] poll error (will retry): ${msg}`); + const status = (e as Error & { status?: number }).status; + if (status && status >= 400 && status < 500 && status !== 429) { + return { ok: false, error: `video poll failed permanently: HTTP ${status}` }; + } + console.warn(`[videos] poll error (will retry): ${e instanceof Error ? e.message : String(e)}`); } const elapsed = Math.floor((Date.now() - start) / 1000); @@ -132,7 +138,7 @@ export function buildVideoResult(path: string, prompt: string, model: string): V path, files: [path], count: 1, - markdown: `[video](${path})`, + markdown: `[video](${pathToFileURL(path).href})`, }; } diff --git a/src/images/xai-video-client.ts b/src/images/xai-video-client.ts index d45596970a..e1360b89ee 100644 --- a/src/images/xai-video-client.ts +++ b/src/images/xai-video-client.ts @@ -53,6 +53,7 @@ async function readBoundedText(resp: Response): Promise { } text += decoder.decode(); } finally { + try { await reader.cancel(); } catch { /* ignore */ } reader.releaseLock(); } return text; @@ -89,7 +90,9 @@ export async function submitVideoJob( if (!resp.ok) { try { await resp.body?.cancel(); } catch { /* ignore */ } - throw new Error("xAI videos API returned " + resp.status); + const err = new Error("xAI videos API returned " + resp.status) as Error & { status: number }; + err.status = resp.status; + throw err; } const text = await readBoundedText(resp); @@ -123,7 +126,9 @@ export async function pollVideoJob( if (!resp.ok) { try { await resp.body?.cancel(); } catch { /* ignore */ } - throw new Error("xAI videos poll API returned " + resp.status); + const err = new Error("xAI videos poll API returned " + resp.status) as Error & { status: number }; + err.status = resp.status; + throw err; } const text = await readBoundedText(resp); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 5e8f31ab50..4bfdfcf658 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1600,11 +1600,16 @@ export async function handleResponses( const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { - // The bridge forces stream:true internally and returns SSE. Non-streaming requests can't be - // served — reject explicitly rather than returning SSE to a client expecting JSON. + // The image bridge detects a hosted image_generation tool and requires streaming. + // The video bridge activates from config and injects a tool — it also needs streaming + // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip + // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic. if (!parsed.stream) { - return formatErrorResponse(400, "invalid_request_error", "media bridge requires stream=true"); - } + if (imgPlan) { + return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); + } + // Video-only: skip bridge for non-streaming requests + } else { // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names. const priorTools = parsed.context.tools ?? []; const bridgeTools = [...priorTools.filter(t => { @@ -1613,6 +1618,7 @@ export async function handleResponses( if (imgPlan && imgPlan.toolNames.has(t.name)) return false; if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; if (vidPlan && vidPlan.toolNames.has(t.name)) return false; + if (vidPlan && t.namespace && vidPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; return true; })]; const existingNames = new Set(bridgeTools.map(t => t.name)); @@ -1639,12 +1645,16 @@ export async function handleResponses( forwardHeaders: selectedForwardHeaders, onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens), abortSignal: options.abortSignal, - maxRounds: imgPlan ? clampImageMaxRounds(config.images?.maxRounds) : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), + maxRounds: imgPlan && vidPlan + ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2)) + : imgPlan + ? clampImageMaxRounds(config.images?.maxRounds) + : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, fetchImpl: providerFetch(route.provider), onRequestBuilt: request => recordAdapterReasoning(logCtx, request), - ...(config.images?.videoTimeoutMs ? { videoTimeoutMs: config.images.videoTimeoutMs } : {}), + ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { // Cursor may assign _cursorConversationId inside the image loop's first runTurn; // backfill so Logs can filter/total that opening request (parity with the normal @@ -1690,6 +1700,7 @@ export async function handleResponses( }); } return imgResponse; + } // end else (streaming bridge) } // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index 26f21ef657..324b877328 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -149,7 +149,7 @@ describe("image bridge dispatch priority (handler activation)", () => { const res = await post(false, [{ type: "image_generation" }]); expect(res.status).toBe(400); expect(imageBridgeRun).toBe(false); - expect((await res.text())).toContain("media bridge requires stream=true"); + expect((await res.text())).toContain("image bridge requires stream=true"); }); test("dual-tool (image_generation + web_search), both eligible → web-search wins, image bridge deferred", async () => { diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts index d9ad75e226..4dbaa3ec32 100644 --- a/tests/videos/fulfill-video.test.ts +++ b/tests/videos/fulfill-video.test.ts @@ -112,7 +112,7 @@ describe("pollVideoWithHeartbeats", () => { } expect(heartbeats.length).toBeGreaterThanOrEqual(1); expect(result.ok).toBe(true); - }); + }, 15_000); // 5s initial poll interval means ~10s for 2 polls test("returns failed status", async () => { mock.module("../../src/images/xai-video-client", () => ({ @@ -158,4 +158,9 @@ describe("buildVideoResult", () => { expect(result.count).toBe(1); expect(result.markdown).toContain("vid-123.mp4"); }); + + test("uses file:// URL in markdown", () => { + const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video"); + expect(result.markdown).toContain("file://"); + }); }); diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts index 4133d46267..cffdf70135 100644 --- a/tests/videos/xai-video-client.test.ts +++ b/tests/videos/xai-video-client.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { describe, expect, test, mock, afterEach } from "bun:test"; import { submitVideoJob, pollVideoJob } from "../../src/images/xai-video-client"; const auth = { baseUrl: "https://api.x.ai/v1", token: "test-key" }; +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); +}); + function mockFetchResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -11,10 +17,6 @@ function mockFetchResponse(body: unknown, status = 200): Response { } describe("submitVideoJob", () => { - beforeEach(() => { - mock.restore(); - }); - test("returns request_id from response", async () => { const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" }))); globalThis.fetch = fetchMock as typeof globalThis.fetch; @@ -68,10 +70,6 @@ describe("submitVideoJob", () => { }); describe("pollVideoJob", () => { - beforeEach(() => { - mock.restore(); - }); - test("returns done status with video URL", async () => { const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "done", From 5e09f3267581d06260103e91a45d4faaf2ebf39f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Jul 2026 22:08:56 +0800 Subject: [PATCH 03/10] fix(videos): encode requestId, defer paidVideoCalls, guard plan! assertion - Encode requestId in poll URL to prevent path injection (CodeRabbit) - Move paidVideoCalls++ past arg validation so malformed calls don't burn budget - Add early guard for undefined plan in image branch, removing non-null assertions --- src/images/loop.ts | 10 +++++++--- src/images/xai-video-client.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/images/loop.ts b/src/images/loop.ts index 1a4a08d480..1b8b20e5b2 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -603,12 +603,12 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise>; if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { result = { ok: false, - model: plan!.model, + model: plan.model, prompt: "", files: [], count: 0, @@ -664,7 +668,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise Date: Wed, 29 Jul 2026 00:07:11 +0800 Subject: [PATCH 04/10] fix(videos): batch-aware artifact pruning + clarify provider key docs - Move pruneArtifacts from per-download to post-batch so multi-video turns don't delete earlier videos before tool results are injected (Codex P2) - Document that video bridge requires providers.xai with apiKey authMode, not OAuth from 'ocx login xai' (Codex P2) --- .../src/content/docs/guides/video-bridge.md | 17 ++++++++++++++++- src/images/loop.ts | 4 +++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md index d2609e93e3..8f397dc892 100644 --- a/docs-site/src/content/docs/guides/video-bridge.md +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -12,10 +12,25 @@ job to xAI, polls until completion, and downloads the result. ## Prerequisites -- An xAI account with an API key (set `XAI_API_KEY` or configure the key in your provider config — `ocx login xai` alone is not sufficient for the video bridge, which requires API key auth) +- An `xai` provider entry with an **API key** (`ocx login xai` alone is not sufficient — the video bridge requires key auth, not OAuth) - A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini) - opencodex configured to route through the non-OpenAI provider +> **⚠ Provider key required:** The video bridge only activates when the `xai` provider uses +> API key auth. Add this to your config: +> +> ```json +> { +> "providers": { +> "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" } +> } +> } +> ``` +> +> If you onboarded via `ocx login xai` (OAuth), the provider stays in `authMode: "oauth"` +> and the bridge silently won't activate. Set `XAI_API_KEY` in the environment **or** +> hard-code the key as shown above. + ## Configuration Add `videoBridgeEnabled: true` to your `images` config: diff --git a/src/images/loop.ts b/src/images/loop.ts index 1b8b20e5b2..e19c75f825 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -633,7 +633,6 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise Date: Wed, 29 Jul 2026 01:08:07 +0800 Subject: [PATCH 05/10] fix(videos): web search coexistence, unified timeout budget, VideoBudget ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Wibias CHANGES_REQUESTED review: 1. Web search coexistence (P1): - When wsPlan is active, media bridge is skipped (existing behavior) - Now emits console.warn so the user sees the skip instead of silent loss - Documented priority rule in video-bridge.md 2. Timeout budget (P2): - Start deadline BEFORE submitVideoJob, not after - Poll receives remaining budget (deadline - now), min 5s floor - Submit (60s) + poll now share one videoTimeoutMs deadline 3. VideoBudget aggregate ceiling (optional, addressed): - VideoBudget now has a cap (600 MiB = 3 × single-download max) - chargeVideoBudget() enforces the ceiling per-chunk during streaming - Exceeding the budget throws mid-download (partial file is cleaned up) 4. New tests: done-without-videoUrl, permanent 4xx poll stop, requestId encoding --- .../src/content/docs/guides/video-bridge.md | 2 + src/images/artifacts.ts | 25 ++++++++++-- src/images/loop.ts | 4 +- src/server/responses/core.ts | 8 ++++ tests/videos/fulfill-video.test.ts | 39 +++++++++++++++++++ tests/videos/xai-video-client.test.ts | 14 +++++++ 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md index 8f397dc892..b2e5b06b9b 100644 --- a/docs-site/src/content/docs/guides/video-bridge.md +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -81,3 +81,5 @@ The `video_gen` tool accepts: - **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p) - **One video per call**: Each `video_gen` call produces one video - **Coexists with Image Bridge**: Both bridges can be enabled simultaneously +- **Web search priority**: When web search is active for a turn, the video bridge is skipped (web search and media bridging cannot run concurrently). A `console.warn` is emitted so you can detect this in logs. +- **Timeout covers submit + poll**: The `videoTimeoutMs` budget starts before job submission, so the submit call (60 s) and subsequent polling share the same deadline. diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 32d65821ff..ec7f26ea25 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -477,13 +477,24 @@ export async function downloadImageToArtifact( } const MAX_VIDEO_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB +/** Aggregate per-turn video download cap (600 MiB = 3 × max single download). */ +const MAX_VIDEO_BYTES_PER_TURN = MAX_VIDEO_DOWNLOAD_BYTES * 3; export interface VideoBudget { spent: number; + /** Ceiling on total bytes across all downloads this turn. */ + cap: number; } export function createVideoBudget(): VideoBudget { - return { spent: 0 }; + return { spent: 0, cap: MAX_VIDEO_BYTES_PER_TURN }; +} + +/** Charge bytes to the budget; returns false if the ceiling would be exceeded. */ +export function chargeVideoBudget(budget: VideoBudget, bytes: number): boolean { + if (budget.spent + bytes > budget.cap) return false; + budget.spent += bytes; + return true; } export function guessVideoExtFromMagic(bytes: Uint8Array): string { @@ -514,7 +525,9 @@ export async function downloadVideoToArtifact( const isBase64 = meta.includes(";base64"); if (!isBase64) throw new Error("non-base64 data URI for video is not supported"); const buf = Buffer.from(data, "base64"); - if (budget) budget.spent += buf.byteLength; + if (budget && !chargeVideoBudget(budget, buf.byteLength)) { + throw new Error("video data URI exceeds per-turn download budget"); + } if (buf.byteLength > MAX_VIDEO_DOWNLOAD_BYTES) throw new Error("video data URI exceeds size cap"); const ext = guessVideoExtFromMagic(buf); const dir = getArtifactsDir(); @@ -564,7 +577,9 @@ export async function downloadVideoToArtifact( fh = await open(dest, "w", 0o600); // Write first chunk and set up accounting let totalBytes = first.value.byteLength; - if (budget) budget.spent += totalBytes; + if (budget && !chargeVideoBudget(budget, totalBytes)) { + throw new Error("video download exceeds per-turn budget"); + } if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { throw new Error("video download exceeds size cap"); } @@ -573,7 +588,9 @@ export async function downloadVideoToArtifact( const { value, done } = await reader.read(); if (done) break; totalBytes += value.byteLength; - if (budget) budget.spent += value.byteLength; + if (budget && !chargeVideoBudget(budget, value.byteLength)) { + throw new Error("video download exceeds per-turn budget"); + } if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { throw new Error("video download exceeds size cap"); } diff --git a/src/images/loop.ts b/src/images/loop.ts index e19c75f825..c60d3f0364 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -610,6 +610,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("timed out"); }); + + test("returns error when done but no videoUrl", async () => { + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => Promise.resolve({ status: "done" as const })), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + } + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("no video URL"); + }); + + test("stops retrying on permanent 4xx poll error", async () => { + let pollCount = 0; + mock.module("../../src/images/xai-video-client", () => ({ + pollVideoJob: mock(() => { + pollCount++; + const err = new Error("xAI videos poll API returned 401") as Error & { status: number }; + err.status = 401; + return Promise.reject(err); + }), + })); + + const ac = new AbortController(); + const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); + let result; + for (;;) { + const { value, done } = await gen.next(); + if (done) { result = value; break; } + } + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("permanently"); + expect(pollCount).toBe(1); + }); }); describe("buildVideoResult", () => { diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts index cffdf70135..fd8d773484 100644 --- a/tests/videos/xai-video-client.test.ts +++ b/tests/videos/xai-video-client.test.ts @@ -127,4 +127,18 @@ describe("pollVideoJob", () => { expect(capturedUrl).toContain("/videos/vid-789"); expect(capturedMethod).toBe("GET"); }); + + test("encodes requestId in poll URL", async () => { + let capturedUrl: string | undefined; + const fetchMock = mock((url: string) => { + capturedUrl = url; + return Promise.resolve(mockFetchResponse({ status: "processing" })); + }); + globalThis.fetch = fetchMock as typeof globalThis.fetch; + + await pollVideoJob("req/with?special&chars", auth); + expect(capturedUrl).toContain(encodeURIComponent("req/with?special&chars")); + // Must NOT contain the raw special chars in the path + expect(capturedUrl).not.toMatch(/\/videos\/req\/with/); + }); }); From a90692364f9aecd959497b0b2a4d002f2fcabe90 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Jul 2026 01:45:00 +0800 Subject: [PATCH 06/10] fix(videos): gate tool_choice on imgPlan, shared deadline signal, buffer magic sniff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Wibias second CHANGES_REQUESTED review: Required: B. Gate image tool_choice rewriting on imgPlan — video-only turns no longer rewrite image_generation/image_gen aliases to an undeclared tool (core.ts) C. Shared timeout complete — deadline-bound AbortSignal passed into submitVideoJob; if budget expired after submit, poll is skipped with timeout error (no 5s floor) Preferred: A. Buffer ≥12 bytes before magic-byte sniff — accumulate chunks until sniff minimum so short first reads don't crash guessVideoExtFromMagic (artifacts.ts) Optional: D. Docs: scope web-search priority to runnable sidecar / non-runTurn path --- .../src/content/docs/guides/video-bridge.md | 2 +- src/images/artifacts.ts | 25 +++++++++++++------ src/images/loop.ts | 16 +++++++++--- src/server/responses/core.ts | 10 +++++--- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md index b2e5b06b9b..4d0c4930f1 100644 --- a/docs-site/src/content/docs/guides/video-bridge.md +++ b/docs-site/src/content/docs/guides/video-bridge.md @@ -81,5 +81,5 @@ The `video_gen` tool accepts: - **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p) - **One video per call**: Each `video_gen` call produces one video - **Coexists with Image Bridge**: Both bridges can be enabled simultaneously -- **Web search priority**: When web search is active for a turn, the video bridge is skipped (web search and media bridging cannot run concurrently). A `console.warn` is emitted so you can detect this in logs. +- **Web search priority**: When a web search sidecar is active for a turn (non-`runTurn` adapter), the video bridge is skipped — the two cannot run concurrently. A `console.warn` is emitted so you can detect this in logs. - **Timeout covers submit + poll**: The `videoTimeoutMs` budget starts before job submission, so the submit call (60 s) and subsequent polling share the same deadline. diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index ec7f26ea25..20e349a9ce 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -562,28 +562,37 @@ export async function downloadVideoToArtifact( const reader = resp.body?.getReader(); if (!reader) throw new Error("video download returned no body"); - // Peek the first chunk for magic-byte sniffing before opening the file. - // If anything fails between acquiring the reader and opening the file, cancel + release the reader. + // Buffer at least 12 bytes for magic-byte sniffing before opening the file. + // A single read() can return fewer bytes; accumulate until we have enough. let dest: string | undefined; let fh: { close(): Promise; writeFile(data: Uint8Array): Promise } | undefined; try { - const first = await reader.read(); - if (first.done || !first.value) { + const sniffChunks: Uint8Array[] = []; + let sniffLen = 0; + while (sniffLen < 12) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + sniffChunks.push(value); + sniffLen += value.byteLength; + } + if (sniffLen === 0) { throw new Error("video download returned empty body"); } - const ext = guessVideoExtFromMagic(first.value); + const sniffBuf = Buffer.concat(sniffChunks); + const ext = guessVideoExtFromMagic(new Uint8Array(sniffBuf)); const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`; dest = join(dir, name); fh = await open(dest, "w", 0o600); - // Write first chunk and set up accounting - let totalBytes = first.value.byteLength; + // Write all buffered chunks and set up accounting + let totalBytes = sniffBuf.byteLength; if (budget && !chargeVideoBudget(budget, totalBytes)) { throw new Error("video download exceeds per-turn budget"); } if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) { throw new Error("video download exceeds size cap"); } - await fh.writeFile(first.value); + await fh.writeFile(new Uint8Array(sniffBuf)); for (;;) { const { value, done } = await reader.read(); if (done) break; diff --git a/src/images/loop.ts b/src/images/loop.ts index c60d3f0364..4525d7317e 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -610,7 +610,13 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise - name === "image_generation" || name === "image_gen" || (imgPlan?.toolNames.has(name) ?? false) + name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false) ? IMAGE_GEN_TOOL_NAME : name, ); parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; - } else if (tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" - && (tc.name === "image_generation" || (imgPlan?.toolNames.has(tc.name) ?? false))) { + } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" + && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; } const imgResponse = await runWithImageBridge({ From c613b05eb828b599ce79528d75a891b6baac261c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Jul 2026 02:05:05 +0800 Subject: [PATCH 07/10] fix(videos): pass deadline-bound signal into poll generator Pass linkedDeadline (not raw client signal) into pollVideoWithHeartbeats so in-flight pollVideoJob fetches and sleep() calls abort when the wall-clock budget expires. Abort error messages now distinguish deadline-expiry from client-cancel based on elapsed time vs timeoutMs threshold. --- src/images/fulfill-video.ts | 9 ++++++++- src/images/loop.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts index 1955f18360..b1e57325db 100644 --- a/src/images/fulfill-video.ts +++ b/src/images/fulfill-video.ts @@ -106,7 +106,11 @@ export async function* pollVideoWithHeartbeats( } // still processing — continue with backoff } catch (e) { - if (signal.aborted) return { ok: false, error: "client closed request during video generation" }; + if (signal.aborted) { + return { ok: false, error: Date.now() - start >= timeoutMs * 0.95 + ? `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` + : "client closed request during video generation" }; + } const status = (e as Error & { status?: number }).status; if (status && status >= 400 && status < 500 && status !== 429) { return { ok: false, error: `video poll failed permanently: HTTP ${status}` }; @@ -120,6 +124,9 @@ export async function* pollVideoWithHeartbeats( try { await sleep(interval, signal); } catch { + if (Date.now() - start >= timeoutMs * 0.95) { + return { ok: false, error: `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` }; + } return { ok: false, error: "client closed request during video generation" }; } diff --git a/src/images/loop.ts b/src/images/loop.ts index 4525d7317e..4fa54e80f8 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -630,7 +630,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise Date: Wed, 29 Jul 2026 02:46:20 +0800 Subject: [PATCH 08/10] fix(videos): deadline abort classification, pruned-path guard, video aliases, DNS noun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Wibias fourth CHANGES_REQUESTED review: 1. Deadline abort classification (required #1): - Replace 0.95 wall-clock heuristic with signal.reason.name === 'TimeoutError' - deadlineSignal declared outside try so catch can check deadlineSignal.aborted - Submit catch returns timeout error when deadlineSignal fires (not generic abort) 2. Don't deliver pruned artifact paths (required #2): - After batch prune, scan fulfilled results and rewrite any whose path no longer exists to ok:false with explanatory error 3. Wire video aliases (required #3): - planVideoBridge seeds toolNames with VIDEO_GEN_TOOL_NAME + any request function tools matching isVideoGenName (image-bridge parity) - core.ts filter only strips unnamespaced video_gen aliases (namespaced MCP tools are left alone) 4. Fix test fixtures (required #4): - authMode 'api_key' → 'key' (valid OcxProviderConfig value) 5. DNS error noun (small): - resolvePublicAddresses accepts optional noun param (default 'image') - downloadVideoToArtifact passes 'video' --- src/images/artifacts.ts | 2 +- src/images/fulfill-video.ts | 18 +++++++++--------- src/images/loop.ts | 22 +++++++++++++++++----- src/images/plan.ts | 13 +++++++++++-- src/lib/destination-policy.ts | 14 +++++++------- src/server/responses/core.ts | 4 ++-- tests/videos/plan-video.test.ts | 4 ++-- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 20e349a9ce..beceb2e25f 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -548,7 +548,7 @@ export async function downloadVideoToArtifact( if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { throw new Error(`video URL targets ${assessment.detail}`); } - const resolved = await resolvePublicAddresses(url); + const resolved = await resolvePublicAddresses(url, "video"); const pinned = pickPinnedAddress(resolved.addresses); const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES }); if (!resp.ok) { diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts index b1e57325db..a9bb34cc5c 100644 --- a/src/images/fulfill-video.ts +++ b/src/images/fulfill-video.ts @@ -84,10 +84,11 @@ export async function* pollVideoWithHeartbeats( > { const start = Date.now(); let interval = INITIAL_POLL_INTERVAL_MS; + const timeoutStr = `video generation timed out after ${Math.floor(timeoutMs / 1000)}s`; for (;;) { if (Date.now() - start >= timeoutMs) { - return { ok: false, error: `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` }; + return { ok: false, error: timeoutStr }; } try { @@ -106,11 +107,11 @@ export async function* pollVideoWithHeartbeats( } // still processing — continue with backoff } catch (e) { - if (signal.aborted) { - return { ok: false, error: Date.now() - start >= timeoutMs * 0.95 - ? `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` - : "client closed request during video generation" }; - } + // Distinguish deadline-expiry from client-cancel. + // AbortSignal.timeout sets signal.reason to a TimeoutError. + const isDeadline = signal.aborted && e instanceof Error && e.name === "TimeoutError"; + if (isDeadline) return { ok: false, error: timeoutStr }; + if (signal.aborted) return { ok: false, error: "client closed request during video generation" }; const status = (e as Error & { status?: number }).status; if (status && status >= 400 && status < 500 && status !== 429) { return { ok: false, error: `video poll failed permanently: HTTP ${status}` }; @@ -124,9 +125,8 @@ export async function* pollVideoWithHeartbeats( try { await sleep(interval, signal); } catch { - if (Date.now() - start >= timeoutMs * 0.95) { - return { ok: false, error: `video generation timed out after ${Math.floor(timeoutMs / 1000)}s` }; - } + const isDeadline = signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError"; + if (isDeadline) return { ok: false, error: timeoutStr }; return { ok: false, error: "client closed request during video generation" }; } diff --git a/src/images/loop.ts b/src/images/loop.ts index 4fa54e80f8..15eeb9cd46 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -11,6 +11,7 @@ * dedup, no describeImages/structuredOutput, no recordSidecarOutcome. */ import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; +import { existsSync } from "node:fs"; import { createAdapterEventQueue } from "../adapters/run-turn-queue"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage } from "../types"; import { namespacedToolName } from "../types"; @@ -609,11 +610,11 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { if (config.images?.videoBridgeEnabled !== true) return undefined; @@ -107,6 +107,15 @@ export async function planVideoBridge( const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); const toolNames = new Set(); toolNames.add(VIDEO_GEN_TOOL_NAME); + // Collect any existing function tools whose name matches a video_gen alias + // so the loop can intercept and replace them (image-bridge parity). + for (const t of parsed.context?.tools ?? []) { + const fnName = typeof t.name === "string" ? t.name + : (t as unknown as { function?: { name?: string } }).function?.name; + if (typeof fnName === "string" && isVideoGenName(fnName)) { + toolNames.add(fnName); + } + } const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs); const keepRaw = config.images?.artifactsKeepCount; const artifactsKeepCount = diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index b2731df684..c2fec9731c 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -219,7 +219,7 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu * so callers can pin the connect peer and avoid a second, rebindable resolution. * DNS resolution failures are treated as unsafe (fail-closed). */ -export async function resolvePublicAddresses(url: string): Promise<{ +export async function resolvePublicAddresses(url: string, noun: string = "image"): Promise<{ hostname: string; addresses: { address: string; family: number }[]; }> { @@ -227,12 +227,12 @@ export async function resolvePublicAddresses(url: string): Promise<{ try { hostname = normalizeHostname(new URL(url.trim()).hostname); } catch { - throw new Error("image URL is not a valid URL"); + throw new Error(`${noun} URL is not a valid URL`); } - if (!hostname) throw new Error("image URL has no hostname"); + if (!hostname) throw new Error(`${noun} URL has no hostname`); const literalAssessment = assessDestination(url); if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") { - throw new Error(`image URL targets ${literalAssessment.detail}`); + throw new Error(`${noun} URL targets ${literalAssessment.detail}`); } // Literal public IPs: no DNS round-trip; pin the literal itself. const literalKind = isIP(hostname); @@ -245,10 +245,10 @@ export async function resolvePublicAddresses(url: string): Promise<{ } catch { // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation, // this is a runtime fetch to an untrusted URL, so be conservative). - throw new Error(`image URL hostname ${hostname} could not be resolved`); + throw new Error(`${noun} URL hostname ${hostname} could not be resolved`); } if (addresses.length === 0) { - throw new Error(`image URL hostname ${hostname} could not be resolved`); + throw new Error(`${noun} URL hostname ${hostname} could not be resolved`); } const publicAddresses: { address: string; family: number }[] = []; for (const { address, family } of addresses) { @@ -257,7 +257,7 @@ export async function resolvePublicAddresses(url: string): Promise<{ const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind !== "public") { - throw new Error(`image URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`); + throw new Error(`${noun} URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`); } publicAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ab9bb8d601..0ebdea95a1 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1625,8 +1625,8 @@ export async function handleResponses( if (t.videoGeneration) return false; if (imgPlan && imgPlan.toolNames.has(t.name)) return false; if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; - if (vidPlan && vidPlan.toolNames.has(t.name)) return false; - if (vidPlan && t.namespace && vidPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone. + if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false; return true; })]; const existingNames = new Set(bridgeTools.map(t => t.name)); diff --git a/tests/videos/plan-video.test.ts b/tests/videos/plan-video.test.ts index 75faf28ee6..84b6127b00 100644 --- a/tests/videos/plan-video.test.ts +++ b/tests/videos/plan-video.test.ts @@ -7,7 +7,7 @@ function makeConfig(overrides: Partial = {}): OcxConfig { const xai: OcxProviderConfig = { name: "xai", baseUrl: "https://api.x.ai/v1", - authMode: "api_key", + authMode: "key", apiKey: "xai-test-key", }; return { @@ -21,7 +21,7 @@ function makeParsed(): OcxParsedRequest { } function makeProvider(host: string): OcxProviderConfig { - return { baseUrl: `https://${host}`, authMode: "api_key", apiKey: "other-key" } as unknown as OcxProviderConfig; + return { baseUrl: `https://${host}`, authMode: "key", apiKey: "other-key" } as unknown as OcxProviderConfig; } describe("planVideoBridge", () => { From cf76fb1e9e20d14556d49f306f80f0417d49ad3a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Jul 2026 03:31:49 +0800 Subject: [PATCH 09/10] fix(videos): complete pruned-path guard, skip namespaced aliases, mock sleep in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Wibias fifth CHANGES_REQUESTED review: 1. Finish pruned-path guard: - Filter files[] through existsSync, not just path - If all pruned: ok:false with no markdown/path - If some survive: refresh path/files/count/markdown from survivors 2. Skip namespaced tools when seeding videoPlan.toolNames: - if (t.namespace) continue — matches core.ts filter rule 3. Fix ubuntu CI timeout: - Mock setTimeout in heartbeat test so 5s poll interval resolves instantly - Suite now runs in ~156ms (was ~5s) --- src/images/fulfill-video.ts | 2 +- src/images/loop.ts | 25 +++++++++++++++++++++++-- src/images/plan.ts | 2 ++ tests/videos/fulfill-video.test.ts | 7 ++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts index a9bb34cc5c..98637eba0c 100644 --- a/src/images/fulfill-video.ts +++ b/src/images/fulfill-video.ts @@ -17,7 +17,7 @@ const MAX_POLL_INTERVAL_MS = 15_000; const POLL_BACKOFF = 1.5; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min -function sleep(ms: number, signal?: AbortSignal): Promise { +export function sleep(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(new Error("aborted")); return new Promise((resolve, reject) => { const onAbort = (): void => { clearTimeout(timer); reject(new Error("aborted")); }; diff --git a/src/images/loop.ts b/src/images/loop.ts index 15eeb9cd46..b98e5b9e74 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -12,6 +12,7 @@ */ import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; import { createAdapterEventQueue } from "../adapters/run-turn-queue"; import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage } from "../types"; import { namespacedToolName } from "../types"; @@ -704,8 +705,28 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise existsSync(p)); + if (survivors.length === f.result.files.length) continue; // nothing pruned + if (survivors.length === 0) { + f.result = { + ok: false, model: f.result.model, prompt: f.result.prompt ?? "", + files: [], count: 0, + error: "artifact was pruned before delivery (increase artifactsKeepCount)", + } as typeof f.result; + } else { + // Some files survived — refresh from survivors + f.result = { ...f.result, files: survivors, count: survivors.length }; + const primary = survivors[0]!; + (f.result as { path?: string }).path = primary; + if ("markdown" in f.result && f.result.markdown) { + // Image markdown references the primary path; video uses pathToFileURL + if (f.result.markdown.startsWith("![")) { + (f.result as { markdown: string }).markdown = `![image](${pathToFileURL(primary).href})`; + } else { + (f.result as { markdown: string }).markdown = `[video](${pathToFileURL(primary).href})`; + } + } } } const now = Date.now(); diff --git a/src/images/plan.ts b/src/images/plan.ts index 2e4fb45f49..b706b57567 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -110,6 +110,8 @@ export async function planVideoBridge( // Collect any existing function tools whose name matches a video_gen alias // so the loop can intercept and replace them (image-bridge parity). for (const t of parsed.context?.tools ?? []) { + // Skip namespaced tools — a namespaced MCP video_gen must not be intercepted. + if (t.namespace) continue; const fnName = typeof t.name === "string" ? t.name : (t as unknown as { function?: { name?: string } }).function?.name; if (typeof fnName === "string" && isVideoGenName(fnName)) { diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts index a5de898c75..23562a2d3d 100644 --- a/tests/videos/fulfill-video.test.ts +++ b/tests/videos/fulfill-video.test.ts @@ -90,6 +90,8 @@ describe("pollVideoWithHeartbeats", () => { }); test("yields at least one heartbeat before returning", async () => { + // Mock sleep to resolve immediately so the 5s poll interval doesn't block CI. + const origSetTimeout = globalThis.setTimeout; let callCount = 0; mock.module("../../src/images/xai-video-client", () => ({ pollVideoJob: mock(() => { @@ -100,6 +102,8 @@ describe("pollVideoWithHeartbeats", () => { }); }), })); + // Override setTimeout globally — sleep() uses it internally. + globalThis.setTimeout = ((fn: () => void) => { fn(); return 0 as unknown as NodeJS.Timeout; }) as typeof globalThis.setTimeout; const ac = new AbortController(); const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal, 60_000); @@ -110,9 +114,10 @@ describe("pollVideoWithHeartbeats", () => { if (done) { result = value; break; } heartbeats.push(value.message); } + globalThis.setTimeout = origSetTimeout; expect(heartbeats.length).toBeGreaterThanOrEqual(1); expect(result.ok).toBe(true); - }, 15_000); // 5s initial poll interval means ~10s for 2 polls + }, 5_000); test("returns failed status", async () => { mock.module("../../src/images/xai-video-client", () => ({ From 50bd69639053f92893f7073fdc7e21fc68d6afdc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:58:13 +0200 Subject: [PATCH 10/10] fix(videos): stop hanging ubuntu CI with global setTimeout mock cf76fb1e mocked globalThis.setTimeout to skip the 5s poll sleep. That races parallel Bun workers and cancelled ubuntu-latest at the 12m job cap (938f25e was green; cf76fb1e hung). Inject sleep/poll seams instead and drop mock.module so video tests no longer poison each other. --- src/images/fulfill-video.ts | 14 +++- tests/videos/fulfill-video.test.ts | 125 ++++++++--------------------- 2 files changed, 46 insertions(+), 93 deletions(-) diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts index 98637eba0c..41bda759a0 100644 --- a/src/images/fulfill-video.ts +++ b/src/images/fulfill-video.ts @@ -17,6 +17,13 @@ const MAX_POLL_INTERVAL_MS = 15_000; const POLL_BACKOFF = 1.5; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min +export type SleepFn = (ms: number, signal?: AbortSignal) => Promise; +export type PollVideoJobFn = ( + requestId: string, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, +) => ReturnType; + export function sleep(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.reject(new Error("aborted")); return new Promise((resolve, reject) => { @@ -78,6 +85,9 @@ export async function* pollVideoWithHeartbeats( auth: { baseUrl: string; token: string }, signal: AbortSignal, timeoutMs: number = DEFAULT_VIDEO_TIMEOUT_MS, + /** Test seams — avoid mock.module / global setTimeout (both poison parallel Bun CI). */ + sleepFn: SleepFn = sleep, + pollFn: PollVideoJobFn = pollVideoJob, ): AsyncGenerator< { type: "heartbeat"; message: string }, { ok: true; videoUrl: string } | { ok: false; error: string } @@ -92,7 +102,7 @@ export async function* pollVideoWithHeartbeats( } try { - const poll = await pollVideoJob(requestId, auth, signal); + const poll = await pollFn(requestId, auth, signal); if (poll.status === "done") { if (poll.videoUrl) { return { ok: true, videoUrl: poll.videoUrl }; @@ -123,7 +133,7 @@ export async function* pollVideoWithHeartbeats( yield { type: "heartbeat", message: `Generating video... ${elapsed}s` }; try { - await sleep(interval, signal); + await sleepFn(interval, signal); } catch { const isDeadline = signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError"; if (isDeadline) return { ok: false, error: timeoutStr }; diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts index 23562a2d3d..5baf34faa5 100644 --- a/tests/videos/fulfill-video.test.ts +++ b/tests/videos/fulfill-video.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult } from "../../src/images/fulfill-video"; -import { pollVideoJob } from "../../src/images/xai-video-client"; +import type { PollVideoJobFn } from "../../src/images/fulfill-video"; + +const auth = { baseUrl: "https://api.x.ai/v1", token: "t" }; +const noopSleep = async (): Promise => {}; describe("parseVideoCallArgs", () => { test("parses valid args", () => { @@ -64,127 +67,67 @@ describe("parseVideoCallArgs", () => { }); describe("pollVideoWithHeartbeats", () => { - beforeEach(() => { - mock.restore(); - }); - - test("returns done on first poll", async () => { - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => Promise.resolve({ - status: "done" as const, - videoUrl: "https://cdn.x.ai/v.mp4", - })), - })); - + async function drain( + pollFn: PollVideoJobFn, + timeoutMs = 60_000, + ): Promise<{ heartbeats: string[]; result: { ok: true; videoUrl: string } | { ok: false; error: string } }> { const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); + const gen = pollVideoWithHeartbeats("r1", auth, ac.signal, timeoutMs, noopSleep, pollFn); const heartbeats: string[] = []; - let result; for (;;) { const { value, done } = await gen.next(); - if (done) { result = value; break; } + if (done) return { heartbeats, result: value }; heartbeats.push(value.message); } + } + + test("returns done on first poll", async () => { + const { result } = await drain(async () => ({ + status: "done", + videoUrl: "https://cdn.x.ai/v.mp4", + })); expect(result.ok).toBe(true); if (result.ok) expect(result.videoUrl).toBe("https://cdn.x.ai/v.mp4"); }); test("yields at least one heartbeat before returning", async () => { - // Mock sleep to resolve immediately so the 5s poll interval doesn't block CI. - const origSetTimeout = globalThis.setTimeout; let callCount = 0; - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => { - callCount++; - return Promise.resolve({ - status: callCount >= 2 ? "done" as const : "processing" as const, - ...(callCount >= 2 ? { videoUrl: "https://x.ai/v.mp4" } : {}), - }); - }), - })); - // Override setTimeout globally — sleep() uses it internally. - globalThis.setTimeout = ((fn: () => void) => { fn(); return 0 as unknown as NodeJS.Timeout; }) as typeof globalThis.setTimeout; - - const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal, 60_000); - const heartbeats: string[] = []; - let result; - for (;;) { - const { value, done } = await gen.next(); - if (done) { result = value; break; } - heartbeats.push(value.message); - } - globalThis.setTimeout = origSetTimeout; + const { heartbeats, result } = await drain(async () => { + callCount++; + return callCount >= 2 + ? { status: "done" as const, videoUrl: "https://x.ai/v.mp4" } + : { status: "processing" as const }; + }); expect(heartbeats.length).toBeGreaterThanOrEqual(1); expect(result.ok).toBe(true); - }, 5_000); + }); test("returns failed status", async () => { - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => Promise.resolve({ status: "failed" as const })), - })); - - const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); - let result; - for (;;) { - const { value, done } = await gen.next(); - if (done) { result = value; break; } - } + const { result } = await drain(async () => ({ status: "failed" })); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("video generation failed"); }); test("returns timeout error when timeoutMs exceeded", async () => { - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => Promise.resolve({ status: "processing" as const })), - })); - - const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal, 0); - let result; - for (;;) { - const { value, done } = await gen.next(); - if (done) { result = value; break; } - } + const { result } = await drain(async () => ({ status: "processing" }), 0); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("timed out"); }); test("returns error when done but no videoUrl", async () => { - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => Promise.resolve({ status: "done" as const })), - })); - - const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); - let result; - for (;;) { - const { value, done } = await gen.next(); - if (done) { result = value; break; } - } + const { result } = await drain(async () => ({ status: "done" })); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("no video URL"); }); test("stops retrying on permanent 4xx poll error", async () => { let pollCount = 0; - mock.module("../../src/images/xai-video-client", () => ({ - pollVideoJob: mock(() => { - pollCount++; - const err = new Error("xAI videos poll API returned 401") as Error & { status: number }; - err.status = 401; - return Promise.reject(err); - }), - })); - - const ac = new AbortController(); - const gen = pollVideoWithHeartbeats("r1", { baseUrl: "https://api.x.ai/v1", token: "t" }, ac.signal); - let result; - for (;;) { - const { value, done } = await gen.next(); - if (done) { result = value; break; } - } + const { result } = await drain(async () => { + pollCount++; + const err = new Error("xAI videos poll API returned 401") as Error & { status: number }; + err.status = 401; + throw err; + }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain("permanently"); expect(pollCount).toBe(1);