在测试单个模型时,为应用添加视频生成功能是直截了当的。但当你想要尝试另一个模型时,复杂性就显现出来了。每个提供商都可能有自己的端点、请求参数、任务状态、轮询逻辑和输出格式。这就把一次简单的模型更换变成了又一个需要构建和维护的集成工作。
我们将这一工作流整合到了一个异步视频 API 之后。你向 POST /api/v1/videos 提交提示词,收到一个任务 ID,轮询直到生成完成,然后下载生成好的视频。
在本指南中,我们将从头到尾构建这一流程。我们将使用 Seedance 提交任务,安全地轮询,保存 MP4 文件,然后用 Veo 和 Wan 运行相同的集成。
摘要
- 一个端点,多种视频模型。通过 POST /api/v1/videos 使用 Seedance、Veo、Wan 及其他受支持的模型进行生成。
- 该工作流是异步的。提交任务,轮询其状态,然后下载完成的视频。
- 通过更改模型标识符来切换模型。某些特定于模型的设置(如时长和宽高比)仍需要针对每个模型进行调整,更多细节见第 4 步,但端点、认证、轮询循环和下载逻辑始终不变。
为什么异步 API 比替代方案更好
视频生成所需的时间比典型的 API 响应更长。模型必须生成并协调许多帧,保持它们之间的视觉一致性,有时还要生成匹配的音频。根据模型和请求的设置,这一过程可能需要几秒到几分钟。
在整个期间保持原始 HTTP 请求打开是脆弱的。浏览器会话可能关闭,无服务器函数可能达到其执行限制,或者代理可能在视频准备好之前超时。
异步 API 将提交与完成分离开来:
- 提交生成请求。
- 立即收到任务 ID。
- 单独检查任务状态。
- 生成完成后下载视频。
模型在后台工作时,你的应用可以继续运行。它还可以在重启后恢复任务,因为生成过程绑定在一个持久化的任务 ID 上,而不是一个长时间保持的连接。
直接与单个提供商集成
当你已经明确知道自己想要哪个模型,并且预期不会改变时,直接与单一提供商集成可以运作得很好。你直接使用该提供商的认证方式、请求格式、任务状态、轮询端点和输出响应。
当你想要对比另一个模型时,额外的工作量就显现出来了。新的提供商可能对时长和分辨率使用不同的字段名,或者返回带有不同终态状态的不同任务对象。它可能还要求用另一种方法来下载生成完成的资产。这样一来,你的应用程序就需要第二个客户端、另一组环境变量,以及更多针对特定提供商的错误处理逻辑。
这种方法本身并没有什么问题。它只是意味着切换模型变成了一次集成变更,而不是配置变更,这会让实验速度变慢,并且随着模型列表的增长,维护成本也会上升。
在本地运行视频模型
本地生成能给你最大的控制权。你可以选择模型权重、自定义工作流、将资产保留在自己的环境中,并且无需为每次生成向托管提供商付费。
这种控制权也伴随着基础设施方面的责任。你需要合适的 GPU 算力,以及正确的 Python 和 CUDA 依赖。你还需要足够的存储空间,并为每个模型家族维护一个可用的运行环境。更高的分辨率和更长的视频会增加内存和处理需求,而添加另一个模型可能意味着要下载更多权重或维护另一个工作流。
对于已经运营 GPU 基础设施或需要本地处理的团队来说,这可能是值得的。但如果你的目标是快速添加视频生成功能并测试多个模型,这是一个较重的起点。而通过 OpenRouter 的托管路径则省去了大部分这类搭建工作,这也是本指南其余部分将要介绍的内容。
通过 OpenRouter 使用一个托管 API
我们在所有支持的视频模型之间保持一致的生成生命周期。无论所选模型是 Seedance、Veo、Wan 还是目录中的其他模型,应用程序都使用相同的 API 密钥、POST /api/v1/videos 端点、任务状态流程和输出获取过程。
这些模型仍具备不同的能力。有的可能支持更长的时长,而另一些则提供额外的宽高比、更高分辨率、音频生成或特定于提供商的控制选项。我们通过视频模型端点来呈现这些差异,而不是强行让每个模型都具备完全相同的功能集。
这样既能为你提供稳定的集成体验,又不会掩盖每个模型的独特之处。你的应用可以查询当前的能力、构建有效的请求,并在不替换周边任务基础设施的情况下更换模型。
前提条件与设置
你只需要一个 OpenRouter API 密钥和一个能够发送 HTTP 请求的工具。这里的示例使用 Python 搭配 requests 库,以及 TypeScript 搭配内置的 fetch API,但该工作流程适用于任何能够发起 HTTP 请求的语言。
首先从你的 OpenRouter 账户创建一个 API 密钥,然后将其存储在环境变量中,而不是直接写入源代码:
export OPENROUTER_API_KEY="sk-or-..." 对于 Python 示例,如果你尚未安装 requests,请先安装:
pip install requests OpenRouter 使用 bearer token 对 API 请求进行身份验证。在 Python 中,我们将一次性定义共享值,并在整个指南中重复使用:
import os
import requests
API_KEY = os.environ["OPENROUTER_API_KEY"]
BASE_URL = "https://openrouter.ai/api/v1"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
} 在提交任务之前,你还可以查询视频模型端点,查看当前可用的模型以及每个模型支持的功能:
curl "https://openrouter.ai/api/v1/videos/models" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" 响应中包含每个模型支持的时长、分辨率、宽高比、帧图像支持、音频能力、定价 SKU 以及特定于提供商的参数。这比假设某个视频模型接受的设置也适用于另一个模型要可靠得多。
步骤 1:提交视频生成任务
向 /api/v1/videos 发送一个 POST 请求,并指定视频模型。同时包含一个描述你希望生成内容的提示词。
每个请求都必须包含 model,而 text-to-video 则必须包含 prompt。支持仅凭图像输入生成视频的模型可以省略 prompt。你还可以在所选模型支持的情况下,提供可选设置,例如时长、分辨率、宽高比、音频生成、参考图像和随机种子。
我们将在整个指南中使用相同的提示词:
PROMPT = (
"A paper boat drifting down a rain-slicked gutter at night, "
"neon reflections, slow tracking shot, cinematic lighting"
) 以下函数使用 Seedance 2.0 提交任务:
def submit_video(model: str, prompt: str) -> dict:
response = requests.post(
f"{BASE_URL}/videos",
headers=HEADERS,
json={
"model": model,
"prompt": prompt,
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": False,
},
timeout=60,
)
response.raise_for_status()
return response.json()
job = submit_video(
model="bytedance/seedance-2.0",
prompt=PROMPT,
)
print("Job ID:", job["id"])
print("Status:", job["status"])
print("Polling URL:", job["polling_url"]) 对应的 cURL 请求是:
curl "https://openrouter.ai/api/v1/videos" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting down a rain-slicked gutter at night, neon reflections, slow tracking shot, cinematic lighting",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
}' 请求成功会返回 HTTP 202 Accepted。该响应表示的是一个后台任务,而非已完成的视频:
{
"id": "job-abc123",
"status": "pending",
"polling_url": "https://openrouter.ai/api/v1/videos/job-abc123"
} 在继续之前,请先保存返回的任务 ID。如果你的进程重启,你应该能够恢复跟踪现有任务,而不是重新提交并支付另一次生成费用。
步骤 2:轮询任务直至完成
步骤 1 中返回的 polling_url 指向与你通过 GET /api/v1/videos/{id} 访问的同一个任务资源,它们是同一个端点。视频任务会经历以下状态:
| 状态 | 含义 |
|---|---|
| pending(待处理) | 任务已被接受,正在等待运行 |
| in_progress(进行中) | 服务商正在生成视频 |
| completed(已完成) | 视频已准备好可供下载 |
| failed(失败) | 生成失败 |
| cancelled(已取消) | 任务已被取消 |
| expired(已过期) | 任务超出了其允许的存活时间 |
你的轮询循环应在 completed 状态时返回,并在 failed、cancelled 或 expired 状态时停止并报错。否则,应用程序可能会持续检查一个永远不会产出视频的任务。
文档中记录的响应会将 polling_url 作为完整 URL 返回。下面的 urljoin 调用是防御性编码,也能处理相对路径,因此循环在两种情况下都能正常工作:
import time
from urllib.parse import urljoin
TERMINAL_ERROR_STATES = {
"failed",
"cancelled",
"expired",
}
def poll_video(
initial_job: dict,
interval: float = 30.0,
timeout: float = 3600.0,
) -> dict:
"""Poll until the video completes or reaches an error state."""
polling_url = urljoin(
"https://openrouter.ai",
initial_job["polling_url"],
)
deadline = time.monotonic() + timeout
job = initial_job
while True:
status = job["status"]
print("Status:", status)
if status == "completed":
return job
if status in TERMINAL_ERROR_STATES:
error = job.get("error") or "No error details were returned."
raise RuntimeError(
f"Video generation ended with status '{status}': {error}"
)
if status not in {"pending", "in_progress"}:
raise RuntimeError(
f"Received unexpected job status: {status}"
)
if time.monotonic() >= deadline:
raise TimeoutError(
f"Job {job['id']} did not complete within "
f"{timeout} seconds."
)
time.sleep(interval)
response = requests.get(
polling_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
timeout=30,
)
response.raise_for_status()
job = response.json()
completed_job = poll_video(job) 这个循环包含了两项快速示例中常被省略的保障措施。首先,它处理了所有文档中记录的终态,而不仅仅是等待 completed。其次,它设置了一小时的超时时间,这样任务就不会让进程无限期运行。有一个值得了解的边界情况:由于截止时间是在每次 sleep 之前检查,而非之后,在最坏情况下,任务在循环捕获到超时之前,最多可能比名义超时时间多运行一个轮询间隔。对于后台任务来说,这是一个可以接受的权衡。如果你需要硬性上限,也可以在从 sleep 中醒来后立即再次检查截止时间。
我们目前的建议是使用 30 秒的轮询间隔。视频任务通常需要大约 30 秒到几分钟的时间,每秒检查一次并不会让服务商更快完成。该间隔和上述超时上限都属于操作层面的建议,并非端点本身文档化的契约,因此你可以根据自己的工作负载进行调整。
同样的轮询流程,使用 TypeScript 实现:
type VideoJobStatus =
| "pending"
| "in_progress"
| "completed"
| "failed"
| "cancelled"
| "expired";
type VideoJob = {
id: string;
polling_url: string;
status: VideoJobStatus;
error?: string;
unsigned_urls?: string[];
};
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const terminalErrorStates = new Set<VideoJobStatus>([
"failed",
"cancelled",
"expired",
]);
async function pollVideo(
initialJob: VideoJob,
intervalMs = 30_000,
timeoutMs = 3_600_000,
): Promise<VideoJob> {
const pollingUrl = new URL(
initialJob.polling_url,
"https://openrouter.ai",
);
const deadline = Date.now() + timeoutMs;
let job = initialJob;
while (true) {
console.log(`Status: ${job.status}`);
if (job.status === "completed") {
return job;
}
if (terminalErrorStates.has(job.status)) {
throw new Error(
job.error ?? `Video generation ${job.status}`,
);
}
if (Date.now() >= deadline) {
throw new Error(
`Video job ${job.id} did not complete before the timeout`,
);
}
await new Promise((resolve) =>
setTimeout(resolve, intervalMs),
);
const response = await fetch(pollingUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Polling failed: ${response.status} ${await response.text()}`,
);
}
job = (await response.json()) as VideoJob;
}
} 将状态请求失败与视频任务失败区别对待。轮询时出现的临时超时并不能证明生成本身失败。应针对同一任务 ID 重试状态请求,而不是提交新任务。
第 3 步:检索并保存视频
当状态变为 completed 时,任务响应中会包含一个已填充的 unsigned_urls 数组。每个条目指向该任务经过身份验证的内容端点:
GET /api/v1/videos/{jobId}/content?index=0 索引默认为 0。仅当模型返回多个视频输出时才需要更改。尽管字段名如此,这些 URL 并非预签名 URL,因此在轮询时需要在 Authorization 请求头中发送你的 API 密钥,方式与之前相同。
下面的辅助函数在存在未签名 URL 时使用第一个,并在极少数情况下根据任务 ID 重建内容 URL。
def download_video(
job: dict,
output_path: str = "out.mp4",
index: int = 0,
) -> None:
unsigned_urls = job.get("unsigned_urls") or []
download_url = (
unsigned_urls[index]
if len(unsigned_urls) > index
else (
f"{BASE_URL}/videos/"
f"{job['id']}/content?index={index}"
)
)
with requests.get(
download_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
stream=True,
timeout=180,
) as response:
response.raise_for_status()
with open(output_path, "wb") as output_file:
for chunk in response.iter_content(
chunk_size=1024 * 1024
):
if chunk:
output_file.write(chunk)
print(f"Saved {output_path}")
download_video(completed_job) 以分块方式流式传输响应,可避免在将整个 MP4 写入磁盘之前将其全部加载到内存中。
以下是 TypeScript 等效实现。请注意,此版本会将下载内容缓冲到内存中,而不是流式传输到磁盘;对于短视频片段来说这没问题,但如果你经常下载长视频或高分辨率视频,建议改用管道流:
import { writeFile } from "node:fs/promises";
async function downloadVideo(
job: VideoJob,
outputPath = "out.mp4",
index = 0,
): Promise<void> {
const downloadUrl =
job.unsigned_urls?.[index] ??
`https://openrouter.ai/api/v1/videos/` +
`${job.id}/content?index=${index}`;
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Download failed: ${response.status} ` +
`${await response.text()}`,
);
}
const videoBuffer = Buffer.from(
await response.arrayBuffer(),
);
await writeFile(outputPath, videoBuffer);
console.log(`Saved ${outputPath}`);
} 此时,你已获得一个生成的 MP4 文件并保存在磁盘上。请将已完成的视频移至你控制的存储中,而不要将生成端点视为永久文件托管。已完成的任务还可能包含一个 usage 对象,其中包含最终费用,无论你使用哪种语言,该对象都是响应正文的一部分:
usage = completed_job.get("usage") or {}
print("Generation cost:", usage.get("cost"))
print("Used BYOK:", usage.get("is_byok")) 将该值存储在你的内部任务记录中,以便跟踪每次生成的实际成本。
第 4 步:一行代码切换模型
提交、轮询和下载函数并不绑定 Seedance。要使用其他受支持的视频模型,只需更改模型标识符:
# Seedance
MODEL = "bytedance/seedance-2.0"
# Veo
# MODEL = "google/veo-3.1"
# Wan
# MODEL = "alibaba/wan-2.7"
job = submit_video(
model=MODEL,
prompt=PROMPT,
)
completed_job = poll_video(job)
download_video(completed_job) 端点、身份验证、响应结构、状态处理和下载逻辑在所有三个模型中保持一致。不会自动延续的是所有可选设置。切换模型在代码上只是一行改动,但这并不保证任何给定的时长、分辨率或宽高比组合都能在新模型上通过校验。本指南中涉及的这一配置恰好在这三个模型上均可移植使用。
{
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
} 在撰写本文时,实时模型端点显示 Seedance 2.0、Veo 3.1 和 Wan 2.7 均支持该特定组合:四秒、720p、16:9。这是这三个示例共有的配置,并非声称每个模型上所有设置都完全相同。超出此范围,差异很快就会显现:
- Veo 3.1 目前显示支持四秒、六秒和八秒的时长。
- Seedance 2.0 目前显示支持四秒至十五秒的时长以及更多宽高比。
- Wan 2.7 目前显示支持两秒至十秒的时长以及 720p 或 1080p 分辨率。
一个五秒的请求可以通过 Seedance 和 Wan 的验证,但在 Veo 上会失败。这就是为什么你的应用应在提交请求前查询 /api/v1/videos/models,而不是假设某个模型接受的设置也适用于另一个模型。上述数字在依赖它们之前,值得对照该实时端点重新核实,因为模型能力确实会变化。
同一端点还暴露了用于模型特定功能的 allowed_passthrough_parameters。这些是允许你在请求的 provider.options 对象中发送的键,该对象以提供商标识符为键,例如 provider.options["google-vertex"].parameters。只有服务于你请求的提供商的选项会被转发,未识别的键会被丢弃。例如,Veo 目前列出了 negativePrompt 和 enhancePrompt 等控制项,而 Wan 则暴露了包括 negative_prompt 和 prompt_extend 在内的选项。
在投入生产之前,有几件事值得了解
上述代码足以生成并下载一个视频。一旦在生产环境中运行,问题就变了:你需要控制成本、区分任务失败与网络故障、避免重复处理,并在提交进程退出后持续跟踪任务。
在扩展之前先检查成本
视频生成的价格因模型和配置而异。时长、分辨率、音频生成以及服务商的计费方式都会影响最终成本。本地生成则完全改变了成本结构,没有按片段计费,但取而代之的是实实在在的前期硬件和运维成本。托管式 API 则让成本保持可变,并与使用量挂钩,具体是更便宜还是更贵,取决于你的用量规模以及你是否已经拥有相关硬件。
不要在你的应用中内置一个通用的成本计算公式。在显示估算费用或提交大批量任务之前,请查询 `/api/v1/videos/models` 接口,并读取所选模型的 `pricing_skus` 字段。当任务完成后,响应中可以包含一个 `usage` 对象,其中带有该次生成的实际成本:
{
"usage": {
"cost": 0.5,
"is_byok": false
}
} 在运行大批量任务之前,请使用当前的模型数据估算成本,然后将估算值与已完成任务返回的实际 `usage.cost` 值进行对比。这也有助于你发现因更高分辨率、更长时长、生成音频或更换不同模型而导致的意外成本变化。
处理失败时避免产生重复任务
轮询请求失败并不等同于视频生成任务失败。你的应用在检查状态时可能会失去连接,但服务商可能仍在生成视频。如果你立即重新提交相同的提示词,两个任务可能都会完成,导致一次用户请求却产生两个视频和两笔费用。
一旦提交成功,请立即持久化保存 OpenRouter 任务 ID。一个有用的任务记录可能包含如下字段:
{
"internal_request_id": "req_9f21",
"openrouter_job_id": "job-abc123",
"model": "bytedance/seedance-2.0",
"status": "pending",
"attempt_number": 1,
"submitted_at": "2026-07-27T12:00:00Z",
"output_location": null,
"cost": null,
"error": null
} 当状态请求因超时、连接错误或临时性服务器响应而失败时,请使用现有的任务 ID 重试该状态请求。只有当任务本身达到失败(failed)、已取消(cancelled)或已过期(expired)状态,并且你的应用重试策略允许再次尝试时,才创建新的生成任务。
将任务重试与轮询重试分开处理。轮询重试是再次检查同一个任务,而生成重试则会创建一个新的付费任务。请限制生成重试的次数,并保留为同一个内部请求创建的所有任务 ID,这样在需要调查重复输出、服务商故障或意外成本时,你就能拥有完整的记录。
当轮询无法再扩展时,请改用 Webhook
对于脚本、原型以及少量任务来说,轮询是一个不错的默认方案。但当你的应用可能同时运行数百个生成任务时,它的效率就会下降。
要自动接收结果,请在提交任务时包含一个 HTTPS callback_url:
{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting through neon reflections",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"callback_url": "https://example.com/webhooks/openrouter-video"
} 你可以为单个请求设置回调,也可以为整个工作区配置默认回调。请求级别的设置优先于工作区默认值。
当任务达到终态时,我们会发送 Webhook。每次投递都包含一个 X-OpenRouter-Idempotency-Key,例如:
job-abc123-completed 在处理事件之前先存储该值。如果 Webhook 被再次投递,你的处理器就能识别出该任务已被处理过,从而避免重复下载视频或重复启动下一个工作流。
当配置了 Webhook 签名密钥时,请求还会包含一个 X-OpenRouter-Signature。在解析或重新序列化请求体之前,请先根据原始请求体验证签名。生产环境中的处理器随后应保存新的任务状态,快速返回成功响应,并将下载、转码或存储等操作移交给后台工作进程。
在持久化存储中跟踪并发任务
提交和等待是两个独立的操作,因此你的应用可以同时运行多个视频任务。不要为每个任务都启动无限制的轮询循环。请使用有界的工作进程池或任务队列,并控制状态请求和下载操作可以并发执行的数量。
在 Python 中,你可以使用线程池或异步工作队列来处理有限数量的任务。在 TypeScript 中,使用带并发控制的队列比将数千个轮询 Promise 直接传给 Promise.all() 更安全。
具体实现方式并不重要,重要的是以下这些规则:
- 在开始轮询之前保存每个任务 ID。
- 限制活跃的轮询和下载操作数量。
- 工作进程重启后,恢复未完成的任务。
- 不要仅仅因为应用重启就重新提交任务。
- 及时将已完成的视频转移到你自己的存储中。
任务 ID 是您的应用程序与正在进行中的生成任务之间的持久连接。请将其视为应用程序状态的一部分,而不是仅存在于某个运行进程中的值。
综合运用
我们介绍了四个步骤,无论您使用哪个模型,这些步骤都不会改变。您只需通过 POST /api/v1/videos 提交请求,在轮询 GET /api/v1/videos/{id} 时留意全部四种终态,下载结果,并在需要更换模型时修改一个字符串即可。
一旦异步生命周期处理正确,模型就变成了一个设置项,而非架构决策——无论您使用的是本文介绍的三个模型,还是之后新增的任何模型。
如果您正在选择起点,可以先浏览视频模型目录,在确定使用某个模型之前,并排查看其定价和功能。
常见问题解答
OpenRouter 支持视频生成吗?
支持,通过专用的异步 API 实现。您向 POST /api/v1/videos 提交提示词,轮询 GET /api/v1/videos/{id} 直到状态变为 completed,然后下载结果。支持的模型包括 Seedance、Veo、Wan 等,全部通过同一个端点访问。
如何通过 API 从文本生成视频?
向 /api/v1/videos 发送一个包含模型和提示词的 POST 请求。您会收到一个任务 ID 和一个 polling_url,而不是视频本身。持续轮询直到状态变为 completed,然后从 unsigned_urls 或 /content 端点下载结果。
如何轮询异步视频生成任务?
按一定时间间隔调用 GET /api/v1/videos/{id},大约 30 秒一次比较合理,直到状态达到终态:completed、failed、cancelled 或 expired。设置一个超时上限,以免卡住的任务无限期挂起您的进程。
OpenRouter 支持哪些视频模型?
目录中包含 Seedance、Veo、Wan 等模型,并且还在持续增加。查询 GET /api/v1/videos/models 可获取当前列表,以及每个模型支持的分辨率、时长、宽高比和透传参数。
我可以在不重写代码的情况下切换视频模型吗?
是的。请求结构、身份验证和轮询循环在所有模型中都是相同的,只有模型字段会变化。模型专属参数仍然通过 provider.options 透传对象传递给提供商。
在本地生成 AI 视频和通过 API 生成,哪个更便宜?
本地生成在支付硬件费用后没有单条视频的费用,但它需要一块性能足够的 GPU、依赖管理,并且每个模型家族都需要单独配置。托管 API 按生成次数收费,但完全省去了 GPU 和配置环节。哪个对你更便宜,取决于你的使用量以及你是否已经拥有硬件。
AI 视频生成需要多长时间?
通常在三十秒到几分钟之间,具体取决于模型、分辨率和视频片段长度。这就是 API 采用异步而非普通阻塞调用的原因。
视频生成是否支持零数据保留(ZDR)?
不支持。异步检索步骤需要将生成的输出短暂保留以便下载,因此启用了 ZDR 的请求不会被路由到视频生成服务。
Adding video generation to an application is straightforward when you’re testing one model. The complexity shows up when you want to try another. Each provider can have its own endpoint, request parameters, job statuses, polling logic, and output format. That turns a simple model change into another integration to build and maintain.
We put that workflow behind one asynchronous video API. You submit a prompt to POST /api/v1/videos, receive a job ID, poll until generation completes, and then download the finished video.
In this guide, we’ll build that flow from start to finish. We’ll submit a job with Seedance, poll it safely, save the MP4, and then run the same integration with Veo and Wan.
Tl;dr
- One endpoint, multiple video models. Generate with Seedance, Veo, Wan, and other supported models through
POST /api/v1/videos. - The workflow is asynchronous. Submit the job, poll its status, then download the completed video.
- Switch models by changing the model identifier. Some model-specific settings, such as duration and aspect ratio, still need adjusting per model, more on that in Step 4, but the endpoint, auth, polling loop, and download logic never change.
Why an async API works better than the alternatives
Video generation takes longer than a typical API response. A model has to generate and coordinate many frames, maintain visual consistency across them, and sometimes produce matching audio. Depending on the model and requested settings, that process can take from several seconds to a few minutes.
Keeping the original HTTP request open for that entire period is fragile. A browser session can close, a serverless function can reach its execution limit, or a proxy can time out before the video is ready.
An asynchronous API separates submission from completion:
- Submit the generation request.
- Receive a job ID immediately.
- Check the job’s status separately.
- Download the video when generation completes.
Your application can keep running while the model works in the background. It can also recover a job after a restart because generation is attached to a persistent job ID rather than a long-lived connection.
Integrating directly with one provider
A direct provider integration can work well when you already know which model you want and don’t expect that to change. You use the provider’s authentication, request format, job statuses, polling endpoint, and output response.
The additional work becomes visible when you want to compare another model. The new provider may use different field names for duration and resolution, or return a different job object with different terminal statuses. It may also require another method for downloading the finished asset. Your application then needs a second client, another set of environment variables, and more provider-specific error handling.
There’s nothing inherently wrong with that approach. It just means switching models is an integration change instead of a configuration change, which makes experimentation slower and raises the maintenance cost as your model list grows.
Running video models locally
Local generation gives you the most control. You can choose the model weights, customize the workflow, keep assets within your own environment, and avoid paying a hosted provider for every generation.
That control comes with infrastructure responsibilities. You need suitable GPU capacity and the right Python and CUDA dependencies. You also need enough storage and a working environment for each model family. Higher resolutions and longer videos increase memory and processing requirements, and adding another model may mean downloading more weights or maintaining another workflow.
This can be worthwhile for teams that already operate GPU infrastructure or require local processing. It’s a heavier starting point when your goal is to add video generation quickly and test several models. The hosted OpenRouter path removes most of that setup, which is what the rest of this guide covers.
Using one hosted API through OpenRouter
We keep the generation lifecycle consistent across supported video models. The application uses the same API key, POST /api/v1/videos endpoint, job-status flow, and output-retrieval process whether the selected model is Seedance, Veo, Wan, or another model in the catalog.
The models still have different capabilities. One may support longer durations, while another offers additional aspect ratios, higher resolutions, audio generation, or provider-specific controls. We expose those differences through the video-model endpoint rather than forcing every model into an identical feature set.
That gives you a stable integration without hiding what makes each model different. Your application can query the current capabilities, build a valid request, and change models without replacing the surrounding job infrastructure.
Prerequisites and setup
You only need an OpenRouter API key and a tool that can send HTTP requests. The examples here use Python with requests and TypeScript with the built-in fetch API, but the workflow works from any language that can make an HTTP request.
Start by creating an API key from your OpenRouter account, then store it in an environment variable instead of adding it directly to your source code:
export OPENROUTER_API_KEY="sk-or-..." For the Python examples, install requests if you don’t already have it:
pip install requests OpenRouter authenticates API requests with a bearer token. In Python, we’ll define the shared values once and reuse them throughout the guide:
import os
import requests
API_KEY = os.environ["OPENROUTER_API_KEY"]
BASE_URL = "https://openrouter.ai/api/v1"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
} Before submitting a job, you can also query the video-model endpoint to see which models are currently available and what each one supports:
curl "https://openrouter.ai/api/v1/videos/models" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" The response includes each model’s supported durations, resolutions, aspect ratios, frame-image support, audio capabilities, pricing SKUs, and provider-specific parameters. This is more reliable than assuming that settings accepted by one video model will also work with another.
Step 1: Submit a video-generation job
Send a POST request to /api/v1/videos with the video model. Include a prompt that describes what you want to generate.
model is required on every request, and prompt is required for text-to-video. Models that support generating a video from image input alone can omit it. You can also provide optional settings such as duration, resolution, aspect ratio, audio generation, reference images, and a seed when the selected model supports them.
We’ll use the same prompt throughout the guide:
PROMPT = (
"A paper boat drifting down a rain-slicked gutter at night, "
"neon reflections, slow tracking shot, cinematic lighting"
) The following function submits the job using Seedance 2.0:
def submit_video(model: str, prompt: str) -> dict:
response = requests.post(
f"{BASE_URL}/videos",
headers=HEADERS,
json={
"model": model,
"prompt": prompt,
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": False,
},
timeout=60,
)
response.raise_for_status()
return response.json()
job = submit_video(
model="bytedance/seedance-2.0",
prompt=PROMPT,
)
print("Job ID:", job["id"])
print("Status:", job["status"])
print("Polling URL:", job["polling_url"]) The equivalent cURL request is:
curl "https://openrouter.ai/api/v1/videos" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting down a rain-slicked gutter at night, neon reflections, slow tracking shot, cinematic lighting",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
}' A successful request returns HTTP 202 Accepted. The response represents a background job, not the finished video:
{
"id": "job-abc123",
"status": "pending",
"polling_url": "https://openrouter.ai/api/v1/videos/job-abc123"
} Store the returned job ID before continuing. If your process restarts, you should be able to resume tracking the existing job instead of submitting and paying for another generation.
Step 2: Poll the job until it finishes
The polling_url returned in Step 1 points to the same job resource you’d reach at GET /api/v1/videos/{id}, they’re the same endpoint. A video job can move through the following statuses:
| Status | Meaning |
|---|---|
pending | The job has been accepted and is waiting to run |
in_progress | The provider is generating the video |
completed | The video is ready to download |
failed | Generation failed |
cancelled | The job was cancelled |
expired | The job exceeded its allowed lifetime |
Your polling loop should return on completed and stop with an error on failed, cancelled, or expired. Otherwise, the application could keep checking a job that will never produce a video.
Documented responses return polling_url as a complete URL. The urljoin call below is defensive coding that also handles a relative path, so the loop works either way:
import time
from urllib.parse import urljoin
TERMINAL_ERROR_STATES = {
"failed",
"cancelled",
"expired",
}
def poll_video(
initial_job: dict,
interval: float = 30.0,
timeout: float = 3600.0,
) -> dict:
"""Poll until the video completes or reaches an error state."""
polling_url = urljoin(
"https://openrouter.ai",
initial_job["polling_url"],
)
deadline = time.monotonic() + timeout
job = initial_job
while True:
status = job["status"]
print("Status:", status)
if status == "completed":
return job
if status in TERMINAL_ERROR_STATES:
error = job.get("error") or "No error details were returned."
raise RuntimeError(
f"Video generation ended with status '{status}': {error}"
)
if status not in {"pending", "in_progress"}:
raise RuntimeError(
f"Received unexpected job status: {status}"
)
if time.monotonic() >= deadline:
raise TimeoutError(
f"Job {job['id']} did not complete within "
f"{timeout} seconds."
)
time.sleep(interval)
response = requests.get(
polling_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
timeout=30,
)
response.raise_for_status()
job = response.json()
completed_job = poll_video(job) This loop includes two safeguards that quick examples often omit. First, it handles every documented terminal state instead of waiting only for completed. Second, it sets a one-hour timeout so a job can’t leave the process running indefinitely. One edge case worth knowing: because the deadline is checked before each sleep rather than after, a job can run up to one poll interval past the nominal timeout in the worst case before the loop catches it. That’s a fine trade-off for a background job. If you need a hard ceiling, check the deadline again immediately after waking from sleep too.
Our current guidance uses a 30-second polling interval. Video jobs usually take from around 30 seconds to several minutes, and checking every second doesn’t make the provider finish sooner. That interval and the timeout ceiling above are both operational guidance, not a documented contract from the endpoint itself, so tune them to your own workload.
The same polling flow in TypeScript:
type VideoJobStatus =
| "pending"
| "in_progress"
| "completed"
| "failed"
| "cancelled"
| "expired";
type VideoJob = {
id: string;
polling_url: string;
status: VideoJobStatus;
error?: string;
unsigned_urls?: string[];
};
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const terminalErrorStates = new Set<VideoJobStatus>([
"failed",
"cancelled",
"expired",
]);
async function pollVideo(
initialJob: VideoJob,
intervalMs = 30_000,
timeoutMs = 3_600_000,
): Promise<VideoJob> {
const pollingUrl = new URL(
initialJob.polling_url,
"https://openrouter.ai",
);
const deadline = Date.now() + timeoutMs;
let job = initialJob;
while (true) {
console.log(`Status: ${job.status}`);
if (job.status === "completed") {
return job;
}
if (terminalErrorStates.has(job.status)) {
throw new Error(
job.error ?? `Video generation ${job.status}`,
);
}
if (Date.now() >= deadline) {
throw new Error(
`Video job ${job.id} did not complete before the timeout`,
);
}
await new Promise((resolve) =>
setTimeout(resolve, intervalMs),
);
const response = await fetch(pollingUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Polling failed: ${response.status} ${await response.text()}`,
);
}
job = (await response.json()) as VideoJob;
}
} Treat a failed status request differently from a failed video job. A temporary timeout while polling doesn’t prove the generation itself failed. Retry the status request for the same job ID rather than submitting a new job.
Step 3: Retrieve and save the video
When the status becomes completed, the job response includes a populated unsigned_urls array. Each entry points at the job’s authenticated content endpoint:
GET /api/v1/videos/{jobId}/content?index=0 The index defaults to 0. It only needs to change when a model returns multiple video outputs. Despite the field name, these URLs are not presigned, so send your API key in the Authorization header just as you did while polling.
The helper below uses the first unsigned URL when one is present and reconstructs the content URL from the job ID on the rare chance it isn’t.
def download_video(
job: dict,
output_path: str = "out.mp4",
index: int = 0,
) -> None:
unsigned_urls = job.get("unsigned_urls") or []
download_url = (
unsigned_urls[index]
if len(unsigned_urls) > index
else (
f"{BASE_URL}/videos/"
f"{job['id']}/content?index={index}"
)
)
with requests.get(
download_url,
headers={
"Authorization": f"Bearer {API_KEY}",
},
stream=True,
timeout=180,
) as response:
response.raise_for_status()
with open(output_path, "wb") as output_file:
for chunk in response.iter_content(
chunk_size=1024 * 1024
):
if chunk:
output_file.write(chunk)
print(f"Saved {output_path}")
download_video(completed_job) Streaming the response in chunks avoids loading the entire MP4 into memory before writing it to disk.
Here’s the TypeScript equivalent. Note that this version buffers the download into memory rather than streaming it to disk, which is fine for short clips but worth swapping for a piped stream if you’re routinely downloading long or high-resolution video:
import { writeFile } from "node:fs/promises";
async function downloadVideo(
job: VideoJob,
outputPath = "out.mp4",
index = 0,
): Promise<void> {
const downloadUrl =
job.unsigned_urls?.[index] ??
`https://openrouter.ai/api/v1/videos/` +
`${job.id}/content?index=${index}`;
const response = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(
`Download failed: ${response.status} ` +
`${await response.text()}`,
);
}
const videoBuffer = Buffer.from(
await response.arrayBuffer(),
);
await writeFile(outputPath, videoBuffer);
console.log(`Saved ${outputPath}`);
} At this point, you have a generated MP4 on disk. Move completed videos to storage you control instead of treating the generation endpoint as permanent file hosting. The completed job may also include a usage object containing the final cost, which is part of the response body regardless of which language you’re using:
usage = completed_job.get("usage") or {}
print("Generation cost:", usage.get("cost"))
print("Used BYOK:", usage.get("is_byok")) Store that value with your internal job record so you can track the actual cost of each generation.
Step 4: Switch models with one line
The submission, polling, and download functions aren’t tied to Seedance. To use another supported video model, change the model identifier:
# Seedance
MODEL = "bytedance/seedance-2.0"
# Veo
# MODEL = "google/veo-3.1"
# Wan
# MODEL = "alibaba/wan-2.7"
job = submit_video(
model=MODEL,
prompt=PROMPT,
)
completed_job = poll_video(job)
download_video(completed_job) The endpoint, authentication, response shape, status handling, and download logic stay the same across all three. What doesn’t automatically carry over is every optional setting. Model switching is a one-line change to the code, but it isn’t a guarantee that any given duration, resolution, or aspect ratio combination will validate on the new model. This configuration happens to be portable across all three models covered in this guide:
{
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": false
} At the time of writing, the live model endpoint shows Seedance 2.0, Veo 3.1, and Wan 2.7 all supporting that specific combination: four seconds, 720p, 16:9. That’s a shared configuration across these three examples, not a claim that every setting works identically on every model. Move outside it and the differences show up quickly:
- Veo 3.1 currently shows support for four-, six-, and eight-second durations.
- Seedance 2.0 currently shows support for four- to 15-second durations and additional aspect ratios.
- Wan 2.7 currently shows support for two- to 10-second durations and 720p or 1080p resolutions.
A five-second request would validate against Seedance and Wan but fail on Veo. That’s why your application should query /api/v1/videos/models before submitting a request rather than assuming that settings accepted by one model will work on another. The numbers above are worth re-checking against that live endpoint before you rely on them, since model capabilities do change.
The same endpoint also exposes allowed_passthrough_parameters for model-specific features. These are the keys you’re permitted to send inside the request’s provider.options object, which is keyed by provider slug, such as provider.options["google-vertex"].parameters. Only the options for the provider that serves your request are forwarded, and unrecognized keys are dropped. Veo, for example, currently lists controls such as negativePrompt and enhancePrompt, while Wan exposes options including negative_prompt and prompt_extend.
A few things worth knowing before this goes to production
The code above is enough to generate and download one video. Once this is running in production, the questions change: you need to control cost, separate job failures from network failures, avoid duplicate processing, and keep tracking jobs after the submitting process exits.
Check the cost before you scale
Video-generation pricing varies by model and configuration. Duration, resolution, audio generation, and the provider’s billing method can all affect the final cost. Local generation shifts that cost structure entirely, with no per-clip fee but real upfront hardware and maintenance cost instead. A hosted API keeps that cost variable and tied to usage, which is cheaper or more expensive depending on your volume and whether you already own the hardware.
Don’t build one universal cost formula into your application. Query /api/v1/videos/models and read the selected model’s pricing_skus before displaying an estimate or submitting a large batch. When the job completes, the response can include a usage object with the actual cost of that generation:
{
"usage": {
"cost": 0.5,
"is_byok": false
}
} Before running a large batch, estimate the cost using the current model data, then compare the estimate with the actual usage.cost values returned by completed jobs. This also helps you spot unexpected changes caused by a higher resolution, longer duration, generated audio, or a different model.
Handle failures without creating duplicate jobs
A failed polling request isn’t the same as a failed video-generation job. Your application may lose its connection while checking status even though the provider is still generating the video. If you submit the prompt again immediately, both jobs may complete, leaving you with two videos and two charges for one user request.
Persist the OpenRouter job ID as soon as submission succeeds. A useful job record might contain fields like these:
{
"internal_request_id": "req_9f21",
"openrouter_job_id": "job-abc123",
"model": "bytedance/seedance-2.0",
"status": "pending",
"attempt_number": 1,
"submitted_at": "2026-07-27T12:00:00Z",
"output_location": null,
"cost": null,
"error": null
} When a status request fails because of a timeout, connection error, or temporary server response, retry the status request using the existing job ID. Only create a new generation after the job itself reaches failed, cancelled, or expired, and only if your application’s retry policy allows another attempt.
Keep job retries separate from polling retries. A polling retry checks the same job again, while a generation retry creates a new paid job. Cap generation retries and retain every job ID created for the same internal request, so you have a complete record when you need to investigate duplicate outputs, provider failures, or unexpected costs.
Use webhooks when polling stops scaling
Polling is a good default for scripts, prototypes, and small numbers of jobs. It becomes less efficient when your application may have hundreds of generations running at once.
To receive the result automatically, include an HTTPS callback_url when submitting the job:
{
"model": "bytedance/seedance-2.0",
"prompt": "A paper boat drifting through neon reflections",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"callback_url": "https://example.com/webhooks/openrouter-video"
} You can set the callback for an individual request or configure a default callback for the workspace. The request-level value takes precedence over the workspace default.
We send the webhook when the job reaches a terminal state. Each delivery includes an X-OpenRouter-Idempotency-Key, such as:
job-abc123-completed Store that value before processing the event. If the webhook is delivered again, your handler can recognize that the job’s already been handled instead of downloading the video or starting the next workflow twice.
When a webhook signing secret is configured, the request also includes an X-OpenRouter-Signature. Verify the signature against the raw request body before parsing or re-serializing it. A production handler should then save the new job state, return a fast success response, and move downloading, transcoding, or storage work to a background worker.
Track concurrent jobs in durable storage
Submitting and waiting are separate operations, so your application can have multiple video jobs running at the same time. Don’t start an unlimited polling loop for every job. Use a bounded worker pool or job queue, and control how many status requests and downloads can run concurrently.
In Python, you could process a limited number of jobs with a thread pool or an asynchronous worker queue. In TypeScript, a concurrency-controlled queue is safer than passing thousands of polling promises directly to Promise.all().
The exact implementation matters less than these rules:
- Save each job ID before beginning to poll.
- Limit the number of active polling and download operations.
- Resume unfinished jobs after a worker restart.
- Don’t resubmit jobs simply because the application restarted.
- Move completed videos to your own storage promptly.
The job ID is the durable link between your application and the generation already in progress. Treat it as part of your application state, not as a value that exists only inside one running process.
Putting it all together
We’ve covered four steps, and they don’t change no matter which model you’re pointing at. All you do is submit with POST /api/v1/videos, poll GET /api/v1/videos/{id} while watching for all four terminal states, download the result, and change one string when you want a different model.
Once the async lifecycle is right, the model becomes a setting instead of an architecture decision, whether you’re using the three models covered here or any model added later.
If you’re picking a starting point, browse the video model catalog to see pricing and capabilities side by side before committing to one.
Frequently asked questions
Does OpenRouter support video generation?
Yes, through a dedicated asynchronous API. You submit a prompt to POST /api/v1/videos, poll GET /api/v1/videos/{id} until the status is completed, and download the result. Supported models include Seedance, Veo, Wan, and others, all through the same endpoint.
How do I generate a video from text with an API?
Send a POST request to /api/v1/videos with a model and a prompt. You get back a job ID and a polling_url, not the video itself. Poll until the status reaches completed, then download from unsigned_urls or the /content endpoint.
How do I poll an async video generation job?
Call GET /api/v1/videos/{id} on an interval, around 30 seconds is reasonable, until the status reaches a terminal state: completed, failed, cancelled, or expired. Set a timeout ceiling so a stuck job can’t hang your process forever.
Which video models does OpenRouter support?
The catalog includes Seedance, Veo, Wan, and others, and it keeps growing. Query GET /api/v1/videos/models for the current list along with each model’s supported resolutions, durations, aspect ratios, and pass-through parameters.
Can I switch video models without rewriting my code?
Yes. The request shape, authentication, and polling loop are identical across models, only the model field changes. Model-specific parameters still reach the provider through the provider.options pass-through object.
Is it cheaper to generate AI video locally or through an API?
Local generation has no per-clip fee once you’ve paid for the hardware, but it requires a capable GPU, dependency management, and a separate setup per model family. A hosted API charges per generation but skips the GPU and setup entirely. Which one is cheaper for you depends on your volume and whether you already own the hardware.
How long does AI video generation take?
Usually somewhere between thirty seconds and a few minutes, depending on the model, resolution, and clip length. That’s the reason the API is asynchronous instead of a normal blocking call.
Is video generation eligible for Zero Data Retention?
No. The async retrieval step requires the generated output to be briefly retained so it can be downloaded, so requests with ZDR enforced aren’t routed to video generation.