# Asynchronous tasks

Source: https://platform.lyrouter.com/ai_gateway/docs/async/en
Last updated: 2026-10-22

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](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en); 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's `videos.create()` uses the latter). When you submit a form, `input_reference` accepts only a publicly reachable `https://` 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. Submit `first_frame`, `last_frame`, `reference_image` and `reference_audio` as 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](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en).
- **Other tasks for the same model**: some models also offer `POST /v1/videos/regenerations` (video upscaling) and `POST /v1/videos/context` (context enhancement, which produces text). They return the same task object and are queried the same way; see [Multimodal capabilities](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en).
- **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/auto` cannot 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:

```json
{
  "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`:

```json
{
  "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`: `false` means retrying unchanged will not succeed (for example, invalid parameters or failed content moderation), so you need to change the request; `true` means you can retry. When the gateway cannot tell, the field is absent; do not treat a missing field as `false`.
- 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 `/content` before 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](https://platform.lyrouter.com/ai_gateway/docs/rate-errors/en), so query until the status is `completed` before 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_full` at 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:

```bash
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 `/content` redirects 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](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en).
- **Charged on completion**: the cost is settled when the task becomes `completed`; `failed` tasks (including timeouts) are not charged. The submit request itself incurs no cost.
- **Viewing in the logs**: in the [logs](https://platform.lyrouter.com/ai_gateway/docs/logs/en) 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](https://platform.lyrouter.com/ai_gateway/docs/quota/en) 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](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en)). The cURL example requires `jq`; save it as a script file and run it with `bash` (it uses `exit` on errors).

**Video task examples**

cURL:
```bash
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
```

Python:
```python
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"))
```

Node.js:
```js
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](https://platform.lyrouter.com/ai_gateway/docs/multimodal/en) |
| 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](https://platform.lyrouter.com/ai_gateway/docs/gateway-headers/en) |
| 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_reference` accepts 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 as `video.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 header `Content-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](https://platform.lyrouter.com/ai_gateway/docs/errors/en).
