Generate or edit images with TheRouter's async media pipeline. Submit a job, get back a polling_url, and poll until the image is ready in S3.
Why async? Image models take 30–180 seconds. That is longer than the 100s sync deadline at our edge, and longer than most reverse proxies allow. Submitting a job + polling is the only reliable path for production. Sync mode exists but should only be used for quick tests on fast models.
Image-output models on TheRouter (see the full list at /models):
openai/gpt-image-2 (newest, 2026-04-21), openai/gpt-image-1.5, openai/gpt-image-1, openai/gpt-image-1-mini, openai/dall-e-3google/imagen-4, google/imagen-4-ultraxai/grok-image-1Every model in the list supports POST /v1/images/generations. Models whose input_modalities include image also support POST /v1/images/edits.
Client ─POST /v1/images/generations?async=true─▶ Gateway
│ (returns immediately)
Client ◀─── 202 + { id, polling_url } ─────────── Gateway
│
│ every 5–10 s
▼
Client ─GET /v1/jobs/:id ─▶ Gateway ─▶ status JSON
│
queued / in_progress ───────────────────┤ keep polling
succeeded ──────────────────────────────┤ download
failed / cancelled / expired ───────────┘ stop
# Download (two options):
GET unsigned_urls[0] # pre-signed S3 URL (7d TTL)
GET /v1/jobs/:id/content # gateway 302 to S3curl -X POST "https://api.therouter.ai/v1/images/generations?async=true" \
-H "Authorization: Bearer $THEROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-image-2",
"prompt": "a minimal red apple on a white marble table, soft natural light",
"n": 1,
"size": "1024x1024",
"quality": "high"
}'Response (HTTP 202):
{
"id": "img_uxRZK4KwxkWaaHXRfwP0fWUDST",
"object": "image_job",
"status": "queued",
"model": "openai/gpt-image-2",
"created_at": 1778637249,
"polling_url": "https://api.therouter.ai/v1/jobs/img_uxRZK4KwxkWaaHXRfwP0fWUDST",
"expires_at": 1779242049
}| Field | Required | Notes |
|---|---|---|
model | yes | Any image-output alias, e.g. openai/gpt-image-2 |
prompt | yes | Image description. Bilingual prompts (中英文) supported. |
n | no | Defaults to 1. Some providers (OpenRouter) only support n=1. |
size | no | 1024x1024 / 1792x1024 / 1024x1792 |
quality | no | low / medium / high |
background | no | transparent / opaque / auto |
output_format | no | png / webp / jpeg |
?async=true | recommended | Query string. Forces the async branch. Without it, the request goes async automatically when the model's timeout exceeds 100s — but explicit ?async=true avoids any sync-path edge-timeout drift. |
curl "https://api.therouter.ai/v1/jobs/img_uxRZK4KwxkWaaHXRfwP0fWUDST" \
-H "Authorization: Bearer $THEROUTER_API_KEY"Terminal response (succeeded):
{
"id": "img_uxRZK4KwxkWaaHXRfwP0fWUDST",
"object": "image_job",
"status": "succeeded",
"model": "openai/gpt-image-2",
"created_at": 1778637249,
"completed_at": 1778637444,
"polling_url": "https://api.therouter.ai/v1/jobs/...",
"content_url": "https://api.therouter.ai/v1/jobs/.../content",
"unsigned_urls": [
"https://therouter-media-prod.s3.us-east-2.amazonaws.com/...&X-Amz-Signature=..."
],
"image_count": 1,
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"cost_credits": 9
},
"error": null
}| Status | Meaning | Client Action |
|---|---|---|
queued | Persisted to DB, waiting for a worker to pick up | Keep polling |
in_progress | Worker is invoking the upstream model | Keep polling |
succeeded | Image uploaded to S3 | Download via unsigned_urls[0] or content_url |
failed | Upstream / network / parse error. Credits refunded. | Read error.message to decide on retry |
cancelled | Cancelled via DELETE /v1/jobs/:id. Credits refunded. | Terminal |
expired | Older than 7 days. S3 artifact purged. | Re-submit |
unsigned_urls[0] is a pre-signed S3 URL with a 7-day TTL. No auth header needed — the signature is in the query string.
curl -L -o apple.png "<unsigned_urls[0]>"Good for: CDN origins, browser <img src>, client-side caching.
curl -L -o apple.png \
-H "Authorization: Bearer $THEROUTER_API_KEY" \
"https://api.therouter.ai/v1/jobs/img_uxRZK4KwxkWaaHXRfwP0fWUDST/content"The gateway validates tenant ownership and 302-redirects to a freshly signed S3 URL on every request. Good for: long-lived links, server-side audit, per-download access control.
POST /v1/images/edits takes an existing image and a prompt describing what to change. Same async semantics as /v1/images/generations.
curl -X POST "https://api.therouter.ai/v1/images/edits?async=true" \
-H "Authorization: Bearer $THEROUTER_API_KEY" \
-F "model=openai/gpt-image-2" \
-F "prompt=Turn this scene into a watercolor painting" \
-F "size=1024x1024" \
-F "image=@input.png"Returns the same { id, polling_url, … } envelope. Poll thepolling_url like a generation job.
curl -X DELETE "https://api.therouter.ai/v1/jobs/img_xxx" \
-H "Authorization: Bearer $THEROUTER_API_KEY"queued. Once a worker has flipped the job to in_progress, cancel returns { cancelled: false, status: <current> }.import os, time, requests
API = "https://api.therouter.ai"
KEY = os.environ["THEROUTER_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}
# submit
r = requests.post(
f"{API}/v1/images/generations?async=true",
headers={**H, "Content-Type": "application/json"},
json={
"model": "openai/gpt-image-2",
"prompt": "a minimal red apple on a white marble table",
},
timeout=30,
)
r.raise_for_status()
job = r.json()
print("submitted:", job["id"])
# poll
deadline = time.time() + 300
while time.time() < deadline:
s = requests.get(job["polling_url"], headers=H, timeout=15).json()
print("status:", s["status"])
if s["status"] == "succeeded":
url = s["unsigned_urls"][0]
with open("out.png", "wb") as f:
f.write(requests.get(url, timeout=60).content)
print("saved out.png")
break
if s["status"] in ("failed", "cancelled", "expired"):
raise SystemExit(s.get("error") or s["status"])
time.sleep(5)
else:
raise SystemExit("polling timeout")const API = "https://api.therouter.ai";
const KEY = process.env.THEROUTER_API_KEY!;
const H = { Authorization: `Bearer ${KEY}` };
const submit = await fetch(`${API}/v1/images/generations?async=true`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({
model: "openai/gpt-image-2",
prompt: "a minimal red apple on a white marble table",
}),
});
if (!submit.ok) throw new Error(`submit failed: ${submit.status}`);
const job = await submit.json();
console.log("submitted:", job.id);
const deadline = Date.now() + 300_000;
while (Date.now() < deadline) {
const s = await (await fetch(job.polling_url, { headers: H })).json();
console.log("status:", s.status);
if (s.status === "succeeded") {
const buf = await (await fetch(s.unsigned_urls[0])).arrayBuffer();
await Bun.write("out.png", buf);
break;
}
if (["failed", "cancelled", "expired"].includes(s.status)) {
throw new Error(s.error?.message ?? s.status);
}
await new Promise((r) => setTimeout(r, 5_000));
}#!/usr/bin/env bash
set -euo pipefail
: "${THEROUTER_API_KEY:?set THEROUTER_API_KEY}"
BASE=https://api.therouter.ai
RESP=$(curl -sS -X POST "$BASE/v1/images/generations?async=true" \
-H "Authorization: Bearer $THEROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"openai/gpt-image-2","prompt":"a minimal red apple"}')
JOB=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "submitted: $JOB"
for i in $(seq 1 60); do
R=$(curl -sS "$BASE/v1/jobs/$JOB" -H "Authorization: Bearer $THEROUTER_API_KEY")
S=$(echo "$R" | python3 -c "import sys,json;print(json.load(sys.stdin)['status'])")
printf "[%02d t=%03ds] %s\n" "$i" $((i*5)) "$S"
case "$S" in
succeeded) URL=$(echo "$R" | python3 -c "import sys,json;print(json.load(sys.stdin)['unsigned_urls'][0])")
curl -sSL -o out.png "$URL"; echo "saved out.png"; exit 0 ;;
failed|cancelled|expired) echo "$R" | python3 -m json.tool; exit 1 ;;
esac
sleep 5
done
echo "timeout"; exit 1Image jobs are billed in credits. The gateway reserves the estimated cost at submit time and settles to the upstream-reported actual cost at succeeded. failed and cancelled get a full refund; expired settles at the already-reserved amount.
| Model | Approx. credits/image (high quality) |
|---|---|
openai/gpt-image-2 | ~9 credits |
openai/gpt-image-1 | ~7 credits |
google/imagen-4 | ~4 credits |
Live pricing is exposed at GET /v1/models and on each model's detail page under /models.
| Symptom | HTTP | Cause | Fix |
|---|---|---|---|
OpenRouter image gen supports n=1 only | 400 | n > 1 for an OpenRouter-routed model | Set n: 1 |
insufficient_credits_error | 402 | Reservation rejected | Top up balance |
AsyncJobCreditReservationUnavailableError | 503 | Redis reservation transiently unavailable | Back off and retry |
Terminal failed + Network error: fetch failed | 200 | Gateway → provider service network issue | File a support ticket; do not auto-retry |
Terminal failed + missing image data | 200 | Prompt was too vague; model replied with text | Tighten the prompt; avoid demonstrative pronouns ("this image") |
queued for > 30s with no transition | 200 | Worker not draining (rare) | Cancel and re-submit; report to support |
?async=true in production. It removes ambiguity about which path the request will take and protects you from edge timeouts.job.id as soon as you get 202. If the client process dies, you can resume polling by hitting GET /v1/jobs/:id from anywhere.unsigned_urls[0] when possible. It avoids the gateway hop and lets you serve the image directly from CloudFront / S3.error.message and only retry on clearly-transient classes (network 5xx, reservation 503).