Asynchronous tasks
Video generation usually takes several minutes, so LYRouter provides it as asynchronous tasks: POST /v1/videos returns a task ID starting with aigwt_ right after you submit, you then query the status with GET /v1/videos/{id}, and once the task completes you download the video with GET /v1/videos/{id}/content. The gateway polls the provider for task progress on your behalf, and your queries only read the status the gateway has recorded. Costs are settled when the task completes, and failed or timed-out tasks are not charged. For each model's parameters, sizes and billing units, see Multimodal capabilities; this page covers only the task lifecycle.
Lifecycle
| Step | Method and path | Returns |
|---|---|---|
| 1. Submit | POST https://api.lyrouter.com/v1/videos |
Task object with status queued |
| 2. Query | GET https://api.lyrouter.com/v1/videos/{id} |
Task object; status changes as the task progresses |
| 3. Download | GET https://api.lyrouter.com/v1/videos/{id}/content |
302 redirect to the video file URL |
| Delete | DELETE https://api.lyrouter.com/v1/videos/{id} |
For a finished task, returns the task object with "deleted": true; the task record is not deleted. A task in progress returns 409 |
- Submit: authentication is the same as for other endpoints. The request body can be JSON or
multipart/form-data(the official OpenAI SDK'svideos.create()uses the latter). When you submit a form,input_referenceaccepts only a publicly reachablehttps://URL: currently an uploaded file raises no error but is ignored, and the task is generated as if there were no reference video and charged as usual. Submitfirst_frame,last_frame,reference_imageandreference_audioas JSON: currently these fields in a form are replaced with a placeholder value and forwarded to the provider, so the provider rejects the submission or the task fails. For parameters, see "Common video parameters" and the per-model sections in Multimodal capabilities. - Other tasks for the same model: some models also offer
POST /v1/videos/regenerations(video upscaling) andPOST /v1/videos/context(context enhancement, which produces text). They return the same task object and are queried the same way; see Multimodal capabilities. - Who can query: any API key in the same workspace can query and download that workspace's tasks; a key from another workspace gets 404.
- Specific models only: virtual models and
lyrouter/autocannot be used with the video endpoints, and a failure does not fall back to the default model.
Task object
Submission and queries return the same kind of task object. Here is an example of a completed task:
{
"id": "aigwt_volcengine_videos_MXgno8VlzuzZtJl0kq83jA",
"object": "video",
"model": "doubao-seedance-2-0-mini-260615",
"status": "completed",
"progress": 100,
"created_at": 1791950400,
"completed_at": 1791950538,
"expires_at": 1792555200,
"seconds": 5,
"size": "1280x720",
"usage": { "duration_seconds": 5, "completion_tokens": 108900 }
}
| Field | Description |
|---|---|
id |
The gateway's task ID, in the format aigwt_<provider>_<task type>_<random string>. It is not the provider's task ID; when you report a problem to the provider, use error.provider_request_id |
object |
Always video |
model |
The upstream model name actually called, which can be written differently from the model ID in the request |
status |
queued, in_progress, completed or failed; see the table below |
progress |
0 before completion and 100 after; it does not show intermediate progress, so rely on status |
created_at / completed_at |
Submission time and end time (Unix seconds). completed_at appears only after the task ends |
expires_at |
When the task record expires, 7 days after submission. After that, queries return 404 |
seconds / size |
Echo the values you submitted |
output_text |
Context enhancement tasks only: the enhanced prompt |
error |
Present only when the task is failed; see Failed tasks below |
usage |
Metering after the task completes: duration_seconds (seconds of video) for video tasks, plus completion_tokens for token-billed models such as Seedance; completion_tokens for context enhancement tasks |
status |
Meaning | What to do |
|---|---|---|
queued |
Accepted; generation has not started | Keep polling |
in_progress |
The provider is generating | Keep polling |
completed |
Finished | Call /content to download, or read output_text |
failed |
Failed | Read error; you are not charged |
Failed tasks
Querying a failed task still returns HTTP 200, with status failed and the reason in error:
{
"id": "aigwt_volcengine_videos_Qw3hTz0bVn1sLk8pYc4dRe",
"object": "video",
"model": "doubao-seedance-2-0-mini-260615",
"status": "failed",
"progress": 0,
"created_at": 1791950400,
"completed_at": 1791950482,
"expires_at": 1792555200,
"seconds": 5,
"error": {
"code": "OutputVideoSensitiveContentDetected",
"message": "The request failed because the output video may contain sensitive information.",
"provider_request_id": "021791950538123abcdef",
"retryable": false
}
}
error.code |
Meaning |
|---|---|
deadline_exceeded |
Not finished 24 hours after submission. This status is not currently propagated to the query endpoint, so GET /v1/videos/{id} may keep showing queued or in_progress; handle it as described under "Set a waiting limit" below |
canceled |
The task was canceled. Canceling a task in progress is not currently supported, so this does not occur in normal use |
upstream_error |
The provider reported a failure without a recognizable error code |
| Provider error codes | Returned as is, for example InvalidParameter or OutputVideoSensitiveContentDetected |
error.provider_request_id: the provider's own request identifier; include it when you contact the provider or LYRouter support. It is absent when there is none.error.retryable:falsemeans retrying unchanged will not succeed (for example, invalid parameters or failed content moderation), so you need to change the request;truemeans you can retry. When the gateway cannot tell, the field is absent; do not treat a missing field asfalse.- None of these failures are charged.
Polling recommendations
- Interval: start querying about 10 seconds after submitting, then gradually widen to once every 30 seconds. The gateway polls the provider in the background at its own pace, so querying more often does not get you the result sooner.
- Download only after completion: calling
/contentbefore the task completes returns 409. These 4xx responses also count toward the API key's error rate, and frequent calls can trip the circuit breaker, so query until the status iscompletedbefore downloading. - A failed query is not a failed task: if a query times out or hits a network error, keep the task ID and continue querying; do not resubmit. Resubmitting creates a new task and charges again.
- Set a waiting limit: a task still unfinished 24 hours after submission will never produce a result and is not charged, so you can stop polling. In the console, search by task ID on the workspace's Multimedia generation page to see the task's State (such as Generating or Expired), Failure reason and artifact.
- Provider concurrency full: a 429
upstream_concurrency_fullat submission means the provider account has reached its limit of concurrent tasks. This limit is shared per provider account, so switching to another API key does not help; resubmit later.
Downloading the artifact
GET /v1/videos/{id}/content returns 302, and the Location header is the signed video URL provided by the provider. Let the client follow the redirect to download (add -L in cURL, set follow_redirects=True in Python httpx; Node.js fetch follows redirects by default). To get only the URL, do not follow the redirect and read the Location header:
curl -s -o /dev/null -w '%{redirect_url}\n' \
https://api.lyrouter.com/v1/videos/$TASK_ID/content \
-H "Authorization: Bearer $LYROUTER_API_KEY"
- The video URL is fixed when the task completes, and every later call to
/contentredirects to the same URL. How long the URL stays valid is up to the provider, usually 24 hours; once it expires, it cannot be fetched again through the gateway, so download it or copy it to your own storage as soon as you get it. - Context enhancement tasks produce text in
output_text; there is nothing to download from/content.
Billing and settlement
- Price locked at submission: price changes after you submit do not affect this task's cost.
- Quantity from the actual result: when the task completes, it is billed on the metering the provider actually returns (such as seconds of video or token count), not on the values in the request. For each model's billing units and tiers, see Multimodal capabilities.
- Charged on completion: the cost is settled when the task becomes
completed;failedtasks (including timeouts) are not charged. The submit request itself incurs no cost. - Viewing in the logs: in the logs list, turn on the Task type and Cost columns under Columns. For the submit request, Task type shows Async, and Cost shows Settling until the task is settled, then the task's cost. Opening the log details shows "The submit request itself is not charged; the cost belongs to the task it started." together with the task ID; switch to Whole task to see the task's Settlement and Task cost. Failed tasks are never settled, so the log list may keep showing Settling; rely on the task status on the Multimedia generation page.
- Quotas: the task's cost counts toward the applicable Credits quotas when it is settled.
Complete example: submit, poll, download
Replace MODEL_ID with a video model ID from the model marketplace, and set seconds and size to values that model supports (see Multimodal capabilities). The cURL example requires jq; save it as a script file and run it with bash (it uses exit on errors).
BASE=https://api.lyrouter.com/v1
AUTH="Authorization: Bearer $LYROUTER_API_KEY"
# 1. Submit the task; without a task ID, print the response and exit instead of polling
RESP=$(curl -s "$BASE/videos" -H "$AUTH" -H "Content-Type: application/json" \
-d '{"model":"MODEL_ID","prompt":"A corgi running across a lawn, with sunlight shining through the leaves","seconds":5}')
TASK_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$TASK_ID" ]; then echo "Submission failed: $RESP"; exit 1; fi
echo "task: $TASK_ID"
# 2. Poll: start at 10 seconds and widen gradually to 30 seconds until the task ends; wait at most 2 hours
DELAY=10
GIVE_UP_AT=$(( $(date +%s) + 2 * 3600 ))
while true; do
if [ "$(date +%s)" -gt "$GIVE_UP_AT" ]; then
echo "$TASK_ID is still generating; query again later with the same ID"; exit 1
fi
sleep $DELAY
DELAY=$(( DELAY * 3 / 2 > 30 ? 30 : DELAY * 3 / 2 ))
TASK=$(curl -s "$BASE/videos/$TASK_ID" -H "$AUTH")
STATUS=$(echo "$TASK" | jq -r '.status')
echo "status: $STATUS"
case "$STATUS" in
completed|failed) break ;;
queued|in_progress|"") ;; # An empty value is usually a network glitch: keep the task ID and keep querying
*) echo "Query error: $TASK"; exit 1 ;;
esac
done
# 3. Download on completion, or print the reason on failure
if [ "$STATUS" = "completed" ]; then
curl -sL "$BASE/videos/$TASK_ID/content" -H "$AUTH" -o video.mp4
else
echo "$TASK" | jq '.error'
fi
import os
import time
import httpx
BASE = "https://api.lyrouter.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"}
with httpx.Client(headers=HEADERS, timeout=60) as client:
# 1. Submit the task
resp = client.post(f"{BASE}/videos", json={
"model": "MODEL_ID",
"prompt": "A corgi running across a lawn, with sunlight shining through the leaves",
"seconds": 5,
})
resp.raise_for_status()
dropped = resp.headers.get("X-Gateway-Unsupported-Parameters")
if dropped:
print("These parameters did not take effect:", dropped)
task = resp.json()
task_id = task.get("id")
if not task_id:
raise RuntimeError(f"No task ID returned: {task}")
print("task:", task_id)
# 2. Poll: start at 10 seconds and widen gradually to 30 seconds; wait at most 2 hours
delay, give_up_at = 10, time.time() + 2 * 3600
while task["status"] in ("queued", "in_progress"):
if time.time() > give_up_at:
raise TimeoutError(f"{task_id} is still generating; query again later with the same ID")
time.sleep(delay)
delay = min(delay * 1.5, 30)
try:
resp = client.get(f"{BASE}/videos/{task_id}")
resp.raise_for_status()
except httpx.TransportError:
continue # Network glitch: keep the task ID and keep querying; do not resubmit
task = resp.json()
if task["status"] == "failed":
err = task.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
# 3. Download: the endpoint returns 302, so just follow the redirect
with client.stream("GET", f"{BASE}/videos/{task_id}/content",
follow_redirects=True, timeout=600) as video:
video.raise_for_status()
with open("video.mp4", "wb") as f:
for chunk in video.iter_bytes():
f.write(chunk)
print("Saved video.mp4, usage:", task.get("usage"))
import { writeFile } from "node:fs/promises";
const BASE = "https://api.lyrouter.com/v1";
const headers = { Authorization: `Bearer ${process.env.LYROUTER_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 1. Submit the task
const submit = await fetch(`${BASE}/videos`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
model: "MODEL_ID",
prompt: "A corgi running across a lawn, with sunlight shining through the leaves",
seconds: 5,
}),
});
if (!submit.ok) throw new Error(await submit.text());
let task = await submit.json();
if (!task.id) throw new Error(`No task ID returned: ${JSON.stringify(task)}`);
console.log("task:", task.id);
// 2. Poll: start at 10 seconds and widen gradually to 30 seconds
let delay = 10_000;
while (task.status === "queued" || task.status === "in_progress") {
await sleep(delay);
delay = Math.min(delay * 1.5, 30_000);
let resp;
try {
resp = await fetch(`${BASE}/videos/${task.id}`, { headers });
} catch {
continue; // Network glitch: keep the task ID and keep querying; do not resubmit
}
if (!resp.ok) throw new Error(await resp.text());
task = await resp.json();
}
if (task.status === "failed") {
throw new Error(`${task.error?.code}: ${task.error?.message}`);
}
// 3. Download: fetch follows the 302 redirect by default
const video = await fetch(`${BASE}/videos/${task.id}/content`, { headers });
if (!video.ok) throw new Error(`download failed: ${video.status}`);
await writeFile("video.mp4", Buffer.from(await video.arrayBuffer()));
console.log("saved video.mp4", task.usage);
Common errors
Error responses take the form {"error":{"message":"...","type":"...","code":"..."}}.
| Status | error.message |
error.code |
Cause and fix |
|---|---|---|---|
| 404 | video task not found |
not_found |
The task ID does not exist, is older than 7 days, belongs to another workspace, or is the provider's task ID. Make sure you use the aigwt_ ID returned at submission |
| 409 | video is not ready yet |
not_ready |
/content was called before the task completed (or after it failed). Query until the status is completed first |
| 409 | cancelling an in-flight video task is not supported: ... |
cancel_not_supported |
DELETE was called on a task in progress. The provider offers no cancel endpoint; wait for the task to end |
| 429 | upstream concurrency full |
upstream_concurrency_full |
The provider account has reached its limit of concurrent tasks. Switching API keys does not help; resubmit later |
| 400 | The validation message for the specific parameter | invalid_parameter |
The video parameters are invalid, for example a size or duration outside the range the model supports. Fix them as the error says; see Multimodal capabilities |
| 400 | unsupported parameters for this model: <parameter name> |
unsupported_parameters |
The request has X-Gateway-Strict-Parameters: true and contains parameters the model does not support; see Gateway headers and extensions |
| 403 | Model is not available in the model plaza |
model_not_available |
The model is not enabled in this workspace, or a virtual model or lyrouter/auto was used. Use a specific model ID that is enabled |
- In a form submission,
input_referenceaccepts only a URL. Currently an uploaded file raises no error: the file is ignored, and the task is generated as if there were no reference video and charged as usual. Only a non-URL text value (such asvideo.mp4) returns a 400 plain-text error:input_reference must be a URL; uploading a file is not supported by this gateway (pass a publicly reachable https:// URL instead). These plain-text errors from the gateway currently still carry the response headerContent-Type: application/json, so check whether the body is JSON before parsing it. - When the provider rejects the request at submission, the status code and error text are set by the provider. The full list is in Errors and request identifiers.