Skip to content
Back to skills

Openproxy Video

ASecurity

Generate video through OpenProxy's async `/v1/videos/*` routes — submit a text-to-video or image-to-video job, poll the returned `request_id` to completion, and download the MP4. Covers the xAI / OpenRouter / Vertex video providers, the per-account cooldown and rotation rules, and the `openproxy media video` CLI that drives the whole flow. Use whenever the user asks to create a video, animate an image, wire a video model into OpenProxy, or debug a stuck or failed video job.

  • 26 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 2, 2026
ai-agentsgobashsqlgcpgitapidatabase

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned October 2, 2026

npx -y skills add quangdang46/openproxy --skill openproxy-video --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Openproxy Video?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Openproxy Video
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/quangdang46-openproxy-video/badge)](https://www.skillsdirectory.com/skills/quangdang46-openproxy-video)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: openproxy-video
description: Generate video through OpenProxy's async `/v1/videos/*` routes — submit a text-to-video or image-to-video job, poll the returned `request_id` to completion, and download the MP4. Covers the xAI / OpenRouter / Vertex video providers, the per-account cooldown and rotation rules, and the `openproxy media video` CLI that drives the whole flow. Use whenever the user asks to create a video, animate an image, wire a video model into OpenProxy, or debug a stuck or failed video job.
---

# openproxy-video — async video generation

Video is the only OpenProxy endpoint family that is **two-phase**. `POST /v1/videos/generations` returns a job id in ~1 second; the MP4 does not exist yet. You must `GET /v1/videos/{id}` until the job reaches a terminal state, then fetch the file from the URL in the finished payload. Treating the create response as the video is the single most common mistake.

Video routes live in `src/server/api/media.rs` and are registered in `src/server/api/mod.rs:220-261`.

## 0 · The five routes that exist

| Method | Path | Handler | Notes |
|---|---|---|---|
| `POST` | `/v1/videos/generations` | `media::video_generations` | Text-to-video and image-to-video. |
| `POST` | `/v1/videos/edits` | `media::video_edits` | xAI Grok Imagine. Multipart-capable. |
| `POST` | `/v1/videos/extensions` | `media::video_extensions` | xAI Grok Imagine. Multipart-capable. |
| `GET` | `/v1/videos/{id}` | `media::video_get` | Poll. Not a download route. |
| `POST` | `/v1/video/generations` | `media::video_generations` | Legacy **singular** spelling, kept for older clients. |

Each is also registered under a doubled prefix — `/v1/v1/videos/generations`, `/v1/v1/videos/edits`, `/v1/v1/videos/extensions`, `/v1/v1/videos/{id}`, `/v1/v1/video/generations` — so a client that already appends `/v1` to its base URL still resolves. There is **no** `GET /v1/videos` list route and **no** content-download route. To get the bytes, read the URL out of the finished poll payload and download it yourself.

All five accept `OPTIONS` preflight.

## 1 · Which providers actually serve video

Exactly three, enforced by `video_provider_supported` (`src/server/api/media.rs:2408`):

| Provider key | Adapter | Upstream | Catalog models (`kind: "video"`) |
|---|---|---|---|
| `xai` | native (default shape) | `https://api.x.ai/v1/videos/{action}` | `grok-imagine-video` |
| `openrouter` | collection-root POST | `https://openrouter.ai/api/v1/videos` | none built in — add a custom model |
| `vertex` | translated to `predictLongRunning` | `https://aiplatform.googleapis.com` | `veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.0-generate-001`, `veo-2.0-generate-001` |

Model selection is read off the `model` field:

- `xai/grok-imagine-video` → provider `xai`, upstream model `grok-imagine-video` (the prefix is stripped before forwarding).
- A bare id with no `provider/` prefix → falls back to **`xai`**.
- A model whose provider is not one of the three → `400 Provider '<provider>' does not support video generation`.
- A **combo** → `400 Combos are not supported for video generation`. Video is one of the route kinds that deliberately does not expand combos — see `combo_expands` at `media.rs:755`, which covers only `audio/speech` and `images/generations`.

## 2 · Connect an account first

Video selects from the ordinary provider connections (`provider_connections` where `provider` is `xai` / `openrouter` / `vertex`, active, holding credentials, not in cooldown). It does **not** read the `/api/media-providers` registry — adding a media provider of some other kind does nothing for these routes.

```bash
export OPENPROXY_API_KEY=...   # the server API key
export OPENPROXY_URL=http://127.0.0.1:4623

openproxy key add video-agent --auto          # → openproxy.v1.key.add envelope
openproxy --robot provider apply --from-file - <<'JSON'
{
  "providers": [
    { "name": "xai-video", "provider": "xai", "apiKey": "xai-...", "isActive": true },
    {
      "name": "vertex-video",
      "provider": "vertex",
      "isActive": true,
      "providerSpecificData": { "projectId": "my-gcp-project", "location": "us-central1" }
    }
  ]
}
JSON
```

Vertex is the one that needs more than a key. The stored credential is **Service Account JSON** (or an OAuth access token) — a plain API key cannot mint a Vertex token, and the code builds Vertex headers from scratch rather than reusing the bearer path. Project id resolves in order: Service Account `project_id` → the connection's `project_id` → `providerSpecificData.projectId`. Location defaults to `us-central1`.

Confirm the account is visible to the video path before spending anything:

```bash
curl -sS "$OPENPROXY_URL/v1/videos/anything" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY"
# 400 "No credentials for provider: xai"  → no account configured
# 400/5xx from the provider               → account found, upstream reachable
```

## 3 · Submit the job

```bash
curl -sS -D /tmp/op-video-headers.txt -X POST \
  "$OPENPROXY_URL/v1/videos/generations" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" \
  -H 'content-type: application/json' \
  -H 'Idempotency-Key: my-unique-key-001' \
  -d '{"model":"xai/grok-imagine-video","prompt":"a paper boat drifting down a rain gutter at dusk","duration":6,"aspect_ratio":"16:9","resolution":"720p"}'
```

Response — the whole thing, no envelope:

```json
{"request_id":"abc-123"}
```

Two things to keep:

1. **Save the response headers.** They carry `x-openproxy-connection-id` and `x-9router-connection-id` — the account that accepted the job.
2. **Send an `Idempotency-Key`.** It is forwarded on every create attempt, including the post-token-refresh retry, so a retried create cannot bill twice. The gateway never generates one for you.

Body fields (OpenAI-ish): `model`, `prompt`, and optionally `duration`, `aspect_ratio`, `resolution`, `n`, `seed`, `negative_prompt`, plus `image` (an object, e.g. `{"url": "..."}`) or `image_url` (a string) for image-to-video. Anything else you send is passed through to the provider untouched — when the model needs no rewriting, the request body goes upstream byte for byte.

`n` and `duration` accept a number or a numeric string. `seed` is special: a numeric `0` is forwarded, because it is a legitimate "make this reproducible" value.

### Image-to-video

```bash
curl -sS -X POST "$OPENPROXY_URL/v1/videos/generations" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"model":"vertex/veo-3.1-generate-preview","prompt":"the camera slowly pushes in","image_url":"gs://my-bucket/frame.png"}'
```

`/v1/videos/edits` and `/v1/videos/extensions` take the same body and additionally accept `multipart/form-data` — the handler branches on `Content-Type` alone, never on which action you hit, and non-JSON bodies are forwarded byte-for-byte (re-encoding a form body would change the multipart boundary). Limits: 32 MiB for the JSON arm, 512 MiB for the raw arm. Note the raw passthrough path is xAI-only; a multipart body is never routed by a `provider/` prefix.

### Vertex body translation

OpenRouter and xAI take your JSON as written. Vertex is translated to the `predictLongRunning` shape before the call, and a `GET` on its job is actually a `POST` to `{operationName}:fetchPredictOperation`:

| You send | Vertex receives |
|---|---|
| `prompt` | `instances[].prompt` |
| `image` / `image_url` (string) | `instances[].image.gcsUri` |
| `image` / `image_url` (`data:…;base64,…`) | `instances[].image.{bytesBase64Encoded,mimeType}` |
| `n` | `parameters.sampleCount` |
| `duration` | `parameters.durationSeconds` |
| `aspect_ratio` | `parameters.aspectRatio` |
| `resolution` | `parameters.resolution` |
| `seed` | `parameters.seed` |
| `negative_prompt` | `parameters.negativePrompt` |

Two Vertex guards fire before any billable call: the model id must be alphanumeric/`.`/`_`/`-` only (anything with `/` or `..` is rejected — it would rewrite the request URL), and the body must carry a `prompt` or a **present** `image`/`image_url`. `"image": null` and `"image": ""` do not satisfy that guard. Vertex and OpenRouter both accept `generations` only; posting to `/v1/videos/edits` with either one returns `400`.

## 4 · Poll until terminal

Video jobs are **account-bound upstream**. Always pin the creating account, or the provider will answer `unknown request_id` when your poll lands on a different credential.

```bash
CONN=$(grep -i '^x-openproxy-connection-id' /tmp/op-video-headers.txt | tr -d '\r' | awk '{print $2}')

curl -sS "$OPENPROXY_URL/v1/videos/abc-123" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" \
  -H "x-connection-id: $CONN"
```

All three spellings are accepted on the way in: `x-connection-id`, `x-openproxy-connection-id`, `x-9router-connection-id`. If you have no connection id, add `?provider=xai|openrouter|vertex`; otherwise the poll falls back to the `xai` default.

The poll response is the provider's own payload. Read these fields:

- `status` — `done` / `completed` means finished; `failed` / `error` / `expired` / `cancelled` means it will never produce a file; anything else (`pending`, `processing`, …) means keep polling.
- `progress` — a percentage on some providers. A ten-minute job with no output reads as a hang, so surface it.
- `video.url`, or `video.file_output.public_url` — the file to download, **only present once terminal**.

Vertex poll responses are synthesized into the same shape rather than passed through: pending becomes `{"id":…,"request_id":…,"status":"pending"}`, failure adds `"error": …`, and a completed operation fills in `video.url` from `response.videos[].gcsUri`.

Treat `429` and `503` on a poll as "try again". Any other non-2xx will not get better — stop.

## 5 · Download the MP4

There is no gateway download route. Fetch the provider URL directly:

```bash
URL=$(curl -sS "$OPENPROXY_URL/v1/videos/abc-123" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" -H "x-connection-id: $CONN" \
  | jq -r '.video.url // .video.file_output.public_url')

curl -fsSL "$URL" -o video.mp4.part && mv video.mp4.part video.mp4
```

Write to a `.part` and rename. A player opened on a still-downloading file sees a truncated stream and no player recovers from that.

## 6 · Or let the CLI do all of it

`openproxy media video` implements exactly the flow above — create, pin, poll, download — and is the recommended path when a human is watching.

```bash
export OPENPROXY_API_KEY=...
openproxy media video \
  --prompt "a paper boat drifting down a rain gutter at dusk" \
  --model xai/grok-imagine-video \
  --duration 6 \
  --aspect-ratio 16:9 \
  --resolution 720p \
  --output boat.mp4
```

Image-to-video — a local path is read and inlined as a base64 data URL (`.png` → `image/png`, `.webp` → `image/webp`, anything else → `image/jpeg`); an `http(s)://` or `data:` URL passes through:

```bash
openproxy media video --prompt "the camera slowly pushes in" --image ./frame.png -o push-in.mp4
```

Agent mode:

```bash
openproxy --robot media video --prompt "…" -o out.mp4
# → {"schema":"openproxy.v1.media.video","ok":true,
#    "data":{"request_id":"abc-123","output":"out.mp4"}}
```

| Flag | Default | Meaning |
|---|---|---|
| `--prompt <TEXT>` | *required* | Video description. |
| `-o, --output <PATH>` | `video.mp4` | Where the finished MP4 lands. |
| `--model <ID>` | `xai/grok-imagine-video` | Model id, `<provider>/<model>` form. |
| `--duration <N>` | — | Clip length in seconds. |
| `--aspect-ratio <R>` | — | e.g. `16:9`, `9:16`, `1:1`. |
| `--resolution <R>` | — | `480p`, `720p`, `1080p`. |
| `--image <PATH\|URL>` | — | First frame, for image-to-video. |
| `--timeout <SEC>` | `600` | Give up on the job after this long. |
| `--poll-interval-ms <MS>` | `5000` | Delay between polls. |
| `--host <H>` / `--port <P>` | resolved runtime | Override the target gateway. Standalone defaults are `127.0.0.1:4623`. |

The CLI does **not** read a key out of the local database. It uses `--api-key`, else `$OPENPROXY_API_KEY`, else the active profile's `api_key_env` / `api_key`. Exported as `OPENPROXY_API_KEY`, it is used for both `x-api-key` and `Authorization: Bearer`.

Behaviour worth knowing: `Ctrl-C` unlinks `<output>.part` and exits `130`; the download is streamed to `.part` and renamed on success; `--timeout` bounds the whole job, not any single HTTP call.

## 7 · Account rotation, cooldowns, and why a job can be stuck

Video has its own rotation and lock rules, separate from the chat pipeline.

**Rotation on create** happens only on `401`, `403`, and `429` — the statuses an upstream returns *before* the job is created. A `5xx` is returned to the caller untouched. On `401`/`403` with a refresh token, the gateway refreshes, persists the new token, and re-sends the identical body exactly once, preserving the `Idempotency-Key`. `openproxy media video` mirrors this by pinning the first account on every poll.

**Cooldowns after a failure**, keyed per model rather than account-wide, so one bad prompt does not take a healthy credential out of every other model's rotation:

| Upstream status | Cooldown |
|---|---|
| `401` | 900 s |
| `403` | 900 s |
| `429` | 120 s (and bumps the account's backoff level) |
| `503` | 120 s |
| any other failure | 300 s |

A `2xx` clears both the lock and the error state, so the account comes straight back.

**When nothing is selectable**, the gateway distinguishes the two cases:

- No account configured for the provider at all → `400 No credentials for provider: <provider>`.
- Every account is cooling down → the last observed status (or `503` if nothing was attempted), message `[<provider>/video] <last error>`, and a `Retry-After` header in seconds. **Honor `Retry-After` and do not retry sooner** — hammering a locked video account is the failure mode the lock exists to prevent.

**Per-request deadline**: `VIDEO_FETCH_TIMEOUT_MS` (default `120000`) bounds each individual upstream HTTP call, not the job. Exceeding it is `408`; a refused connection is `502`. If a video job consistently times out at 120 s, raise this — but confirm the upstream is slow rather than your proxy path first.

## 8 · Error shapes

Errors use the standard OpenAI error envelope:

```json
{"error":{"code":"…","message":"…","type":"…"}}
```

Upstream error bodies are forwarded with a `[provider] ` prefix, truncated to 2000 characters, and scrubbed — `Bearer <token>` and any stored access / refresh / API key are redacted before the text reaches you. Seeing a redacted secret in an error means the credential is being sent correctly; it is not a leak.

| Status | Cause |
|---|---|
| `400` | `Combos are not supported for video generation` |
| `400` | `Provider '<p>' does not support video generation` |
| `400` | `No credentials for provider: <p>` |
| `400` | `OpenRouter video requires an application/json body` |
| `400` | `OpenRouter video supports 'generations' only` / `Vertex video supports 'generations' only` |
| `400` | `Vertex video requires a prompt or an image` / `Invalid Vertex video model id` |
| `401` | Missing or unknown `OPENPROXY_API_KEY` on the gateway |
| `408` | Upstream exceeded `VIDEO_FETCH_TIMEOUT_MS` |
| `502` | Upstream refused or reset the connection |
| `503` | Every account cooling down; read `Retry-After` |

## 9 · Auth model

The video routes gate on `require_api_key_with_reload`, and only when `settings.require_api_key()` is on. They deliberately do **not** gate on dashboard login — locking the dashboard must not lock every API client, and the usual headless posture (dashboard open) must not silently remove API auth from `/v1/*`.

Present the key either way; both are read:

```bash
-H "Authorization: Bearer $OPENPROXY_API_KEY"
-H "x-api-key: $OPENPROXY_API_KEY"
```

A `401 Invalid API key` right after minting a key usually means a stale server snapshot — the auth path reloads once from SQLite and retries, so `openproxy server stop && openproxy server start --detach --no-open` clears it if it persists.

## 10 · Verify an end-to-end job

```bash
# 1. Create and capture both the id and the account
curl -sS -D /tmp/h.txt -X POST "$OPENPROXY_URL/v1/videos/generations" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" -H 'content-type: application/json' \
  -H "Idempotency-Key: verify-$(date +%s)" \
  -d '{"model":"xai/grok-imagine-video","prompt":"a red ball rolling down a hill"}' \
  -o /tmp/create.json
cat /tmp/create.json
CONN=$(grep -i '^x-openproxy-connection-id' /tmp/h.txt | tr -d '\r' | awk '{print $2}')
RID=$(jq -r '.request_id' /tmp/create.json)

# 2. Poll, pinned to the creating account
until S=$(curl -sS "$OPENPROXY_URL/v1/videos/$RID" \
    -H "Authorization: Bearer $OPENPROXY_API_KEY" -H "x-connection-id: $CONN" \
    | jq -r '.status'); do sleep 5; done
echo "status=$S"

# 3. Download only once terminal
[ "$S" = done ] || [ "$S" = completed ] || exit 1
URL=$(curl -sS "$OPENPROXY_URL/v1/videos/$RID" \
  -H "Authorization: Bearer $OPENPROXY_API_KEY" -H "x-connection-id: $CONN" \
  | jq -r '.video.url // .video.file_output.public_url')
curl -fsSL "$URL" -o verify.mp4.part && mv verify.mp4.part verify.mp4
ls -l verify.mp4
```

Or the same check in one command:

```bash
openproxy --robot media video --prompt "a red ball rolling down a hill" -o verify.mp4
# → data.request_id present, data.output written; a non-zero exit means it never finished
```

## Common failure modes

| Symptom | Cause | Fix |
|---|---|---|
| `No credentials for provider: xai` | No `xai` connection is active | `openproxy --robot provider list`, or dashboard → Providers |
| `Provider 'openai' does not support video generation` | OpenAI is not a video provider here | Use `xai/`, `openrouter/`, or `vertex/` |
| `Combos are not supported for video generation` | Model resolved to a combo | Point at a concrete `<provider>/<model>` |
| `unknown request_id` on every poll | Poll landed on a different account | Echo `x-connection-id` from the create response |
| Job stuck `pending` for minutes | Upstream is genuinely slow, or the account was rotated out | Check `Retry-After`; raise `VIDEO_FETCH_TIMEOUT_MS` if single calls are hitting 408 |
| `429` immediately on a fresh job | Every account is in cooldown | Read `Retry-After` and wait it out — do not retry sooner |
| `Vertex video requires a project_id` | Credential is not Service Account JSON, no project set | Set `providerSpecificData.projectId` or store the SA JSON |
| `Vertex video requires a prompt or an image` | Body had only `"image": null` / `""` | Send a real prompt, or a real image URL |
| OpenRouter returns `400` on create | Body was not `application/json` | OpenRouter has no multipart arm — send JSON |
| A `Bearer [redacted]` string appears in an error | Working as designed | Secrets are scrubbed from every client-bound video error |

## Environment variables

| Var | Default | Purpose |
|---|---|---|
| `VIDEO_FETCH_TIMEOUT_MS` | `120000` | Per-request upstream deadline. Bounds the HTTP call, not the job. |
| `OPENPROXY_XAI_VIDEO_URL` | `https://api.x.ai/v1/videos` | Redirect the xAI video upstream. |
| `OPENPROXY_API_KEY` | _(unset)_ | Bearer the CLI sends to the gateway. |
| `OPENPROXY_URL` | `http://127.0.0.1:4623` | Gateway the CLI targets. |
| `require_api_key` (setting, not env) | `false` | When enabled, `/v1/videos/*` rejects unauthenticated callers. There is no `REQUIRE_API_KEY` env var — no code reads it. |

## When _not_ to use this skill

- Chat, image, TTS, STT, or embedding generation — use the other endpoint skills.
- Provider catalog sync or CLI-tool wiring — see [`.agents/skills/openproxy/SKILL.md`](../openproxy/SKILL.md).
- Combo fallback and model resolution across the chat pipeline — see `.agents/skills/openproxy-combos/SKILL.md`.

## See also

- Implementation: `src/server/api/media.rs`, routes registered at `src/server/api/mod.rs:220-261`
- CLI: `src/cli/media.rs` (`openproxy media video`)
- Catalog: `src/core/model/provider_catalog.json` (entries with `"kind": "video"`)
- Intentional divergences: `docs/parity-9router.md` — notably, a video create is **not** re-fired at the next account on a network error; it returns `502` immediately, because a dropped connection may still have billed upstream.
- Upstream docs: [xAI videos](https://docs.x.ai/developers/rest-api-reference/inference/videos) · [OpenRouter videos](https://openrouter.ai/docs/api/api-reference/videos) · [Vertex Veo](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.