Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs-site/src/content/docs/guides/video-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
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` 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:

```json
{
"images": {
"bridgeEnabled": true,
"videoBridgeEnabled": true,
"videoBridgeModel": "grok-imagine-video",
"videoMaxRounds": 2,
"videoTimeoutMs": 300000
}
}
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close both objects in the video bridge JSON example

The configuration sample opens both the root object and its images object but contains only one closing brace, so copying the documented setup produces invalid JSON and prevents opencodex from loading the configuration. Add the missing outer brace and keep the example parseable as written.

AGENTS.md reference: AGENTS.md:L96-L97

Useful? React with 👍 / 👎.

```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

| 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
- **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.
146 changes: 145 additions & 1 deletion src/images/artifacts.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -475,3 +475,147 @@ 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
/** 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, 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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";
throw new Error("unrecognized video format — magic bytes do not match MP4 or WebM");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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<string> {
// 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 && !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();
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, "video");
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");

// 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<void>; writeFile(data: Uint8Array): Promise<void> } | undefined;
try {
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 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 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(new Uint8Array(sniffBuf));
for (;;) {
const { value, done } = await reader.read();
if (done) break;
totalBytes += 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");
}
await fh.writeFile(value);
}
await fh.close();
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;
}
}
Loading
Loading