Image Generation API
Upthos Relay exposes two image generation surfaces under the same auth, the same pricing, and the same upstream pool. Pick the one that fits your client:
| Path | Mode | Use when |
|---|---|---|
/v1/images/generations + /v1/images/edits |
Synchronous passthrough | You're using the OpenAI SDK or any client that expects the standard {data: [...]} response shape. The HTTP request blocks until the upstream returns. |
/v1/images/tasks/* |
Asynchronous task | You want mid-flight cancel, persistent shareable URLs (we upload to R2), opt-in partial-image progress preview (partial_preview: true), or you're behind a tight edge timeout like Cloudflare's 100s idle limit. |
Both paths charge identically: usd_price_per_image × image_count × fx_rate, with no group-rate multiplier — subscription discounts do not apply to image generation regardless of which path or which subscription tier the key sits in.
Authentication
Same as every other gateway endpoint. Pass the API key in any one of:
Authorization: Bearer sk-...
x-api-key: sk-...
x-goog-api-key: sk-...
The key must belong to an OpenAI-platform group (Codex Lite / Pro / Max, etc.). Anthropic / Gemini-platform keys hit 404 not_found_error on both paths.
Synchronous API
POST /v1/images/generations
OpenAI Images API drop-in. Returns the upstream response verbatim (with our model/account selection + failover behind it).
Request body (JSON):
{
"model": "gpt-image-2",
"prompt": "a cyberpunk cat with neon backlight",
"n": 1,
"size": "1024x1024",
"quality": "high",
"response_format": "url"
}
| Field | Type | Notes |
|---|---|---|
model |
string | gpt-image-2 (recommended, default), gpt-image-2.5-flare, gpt-image-2.5-sunburst, gpt-image-1.5, gpt-image-1, gpt-image-1-mini, chatgpt-image-latest, or dall-e-3 |
prompt |
string | The image description. UTF-8 OK. |
n |
int | Number of images. Defaults to 1. |
size |
string | 1024x1024, 1024x1536, 1536x1024, or auto. Mapped to 1K / 2K size tier for billing. |
quality |
string | low / medium / high / auto. auto → medium for pricing lookup. |
response_format |
string | url (default) or b64_json. |
Response (200): standard OpenAI shape:
{
"created": 1715478000,
"data": [
{ "url": "https://oaidalleapiprodscus.blob.core.windows.net/private/..." }
]
}
OpenAI Python SDK example:
from openai import OpenAI
client = OpenAI(
api_key="sk-...",
base_url="https://relay.upthos.com/v1",
)
resp = client.images.generate(
model="gpt-image-2",
prompt="a cyberpunk cat with neon backlight",
size="1024x1024",
quality="high",
n=1,
)
print(resp.data[0].url)
POST /v1/images/edits
Same auth, multipart body. Pass the source image in the image field (PNG/JPEG/WebP up to 4 MB per part). Optional mask. All other fields match /generations.
curl -sS -X POST https://relay.upthos.com/v1/images/edits \
-H "Authorization: Bearer $UPTHOS_API_KEY" \
-F "model=gpt-image-2" \
-F "prompt=replace the background with a forest scene" \
-F "size=1024x1024" \
-F "[email protected]"
When the sync path is the wrong choice
- Long-running renders (
gpt-image-2athighquality can run 60–120 s). Cloudflare's 100s idle timeout will reset the connection before the upstream responds. Use the async API. - Mobile clients that close the socket on app background. Use the async API and poll.
- You want a shareable URL. The OpenAI URL expires within hours. The async API uploads to R2 with a configurable retention (default 30 days).
Asynchronous task API
The task model: submit → get a task_id immediately → poll for progress → fetch the persistent R2 URL when status: completed. Tasks survive client disconnect, can be cancelled, and — when you pass partial_preview: true — emit partial-image previews while in flight.
POST /v1/images/tasks
Submit a generation task. Returns immediately.
curl -sS -X POST https://relay.upthos.com/v1/images/tasks \
-H "Authorization: Bearer $UPTHOS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "a cyberpunk cat with neon backlight",
"size": "1:1",
"resolution": "1k",
"n": 1
}'
Request fields:
| Field | Type | Notes |
|---|---|---|
model |
string | Same model set as the sync API |
prompt |
string | UTF-8 prompt text |
n |
int | 1 by default, supports 1..4. A single task can generate multiple images; the frontend reveals them progressively as each completes. |
size |
string | Aspect ratio: 1:1 / 16:9 / 9:16 / 3:2 / 2:3 / auto |
resolution |
string | 1k (default) / 2k / 4k |
image_urls |
string[] | Optional. Reference images for img2img — see /v1/images/uploads below. |
partial_preview |
bool | Optional, false by default. true asks the upstream for partial-image frames and exposes the latest one as progress_image_url while the task is processing. It roughly triples the bytes we pull from the upstream for the same final image, so only turn it on if you actually render the preview. |
Response (200):
{
"code": 200,
"data": [
{ "status": "submitted", "task_id": "task_01HZQ3R..." }
]
}
The submit endpoint pre-checks balance. If the estimated max cost exceeds the key's available balance, you'll get 402 INSUFFICIENT_BALANCE before the task is queued.
GET /v1/images/tasks/:id
Poll one task. Recommended cadence: every 3–5 seconds.
curl -sS https://relay.upthos.com/v1/images/tasks/task_01HZQ3R... \
-H "Authorization: Bearer $UPTHOS_API_KEY"
Response shape:
{
"code": 200,
"data": {
"id": "task_01HZQ3R...",
"status": "completed",
"progress": 100,
"created": 1715478000,
"completed": 1715478060,
"actual_time": 58000,
"estimated_time": 100000,
"result": {
"images": [
{
"url": ["https://r2.upthos.com/..."],
"expires_at": 1718070000
}
]
},
"progress_image_url": null
}
}
status is one of submitted / processing / completed / failed.
While status === "processing", progress_image_url may contain an intermediate preview URL — but only for tasks submitted with partial_preview: true. For those, the upstream emits partial images during streaming, we upload each frame to R2 and surface the latest here; use it to render a progressive preview without changing your polling logic. Without the flag the field stays null for the whole lifetime of the task.
On failure, the body carries error: { code, message, type } instead of result:
{
"code": 200,
"data": {
"id": "task_01HZQ3R...",
"status": "failed",
"error": {
"code": "upstream_rate_limit",
"message": "Upstream returned 429 after 3 retries",
"type": "upstream_error"
}
}
}
Common error.code values:
| Code | Meaning |
|---|---|
client_cancelled |
You called /cancel on this task |
worker_timeout |
The upstream call exceeded the worker's per-task timeout (default 15 min) |
upstream_rate_limit |
429 from upstream after all retries |
upstream_overload |
529 from upstream after all retries |
upstream_error |
Generic upstream failure |
internal_error |
Worker-side error — file a bug |
POST /v1/images/tasks/batch
Poll up to 100 tasks at once.
curl -sS -X POST https://relay.upthos.com/v1/images/tasks/batch \
-H "Authorization: Bearer $UPTHOS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": ["task_01HZQ3R...", "task_01HZQ4S..."] }'
Returns the same per-task shape as the single-task endpoint, in an array. Order matches the input.
POST /v1/images/tasks/:id/cancel
Idempotent. Interrupts the upstream HTTP call if the task is still in flight, then marks it failed with error.code = client_cancelled. Calling on a task that's already terminal returns 200 with no effect.
curl -sS -X POST https://relay.upthos.com/v1/images/tasks/task_01HZQ3R.../cancel \
-H "Authorization: Bearer $UPTHOS_API_KEY"
POST /v1/images/uploads — img2img reference upload
Stores a reference image in R2 for use as image_urls[] in a subsequent submit. Returns a short-lived public URL (24h TTL).
curl -sS -X POST https://relay.upthos.com/v1/images/uploads \
-H "Authorization: Bearer $UPTHOS_API_KEY" \
-F "[email protected]"
Response:
{
"code": 200,
"data": {
"url": "https://r2.upthos.com/inputs/...",
"expires_at": 1715564400
}
}
Notes:
- Single file per request. PNG / JPEG / WebP.
- Max 2 MiB after server-side validation. Client-side resize is recommended — a few hundred KB is the sweet spot.
- The url is public. Don't upload anything you wouldn't share.
- Pass the returned
urlback viaimage_urls: ["..."]in the next submit.
When the async path is the right choice
- Long renders that would otherwise hit CF's 100s idle timeout
- Mobile clients with backgrounding
- You need a shareable URL (gallery, history, social embed)
- You want partial-image progress (
progress_image_url, opt in withpartial_preview: true) - You want to cancel mid-flight (UX cost: a single button press)
Pricing
Image prices are configured per resolution → real USD under "Channel Management → Channel Pricing → billing_mode=image", then multiplied by image count and the display fx_rate (~6.82 CNY/USD). The result is the CNY retail price. It is the same across all groups regardless of subscription discount — subscription rates only apply to text/code tokens.
For gpt-image-2 at our standard rates:
| Resolution | Pixel size | Price per image (CNY) |
|---|---|---|
| 1K | 1024×1024 | ¥0.041 |
| 2K | 1024×1536 or 1536×1024 | ¥0.082 |
| 4K | (when supported) | ¥0.123 |
gpt-image-2.5-flare and gpt-image-2.5-sunburst cost exactly the same as the table above (their official token rates match gpt-image-2), and are likewise billed on resolution only.
quality is an upstream-recognized field (low / medium / high / auto) that does not affect billing — pricing only looks at resolution. Keep high on by default unless you have a reason otherwise.
If you submit a model that's not configured in channel pricing, we fall back to the legacy image_price_{1k|2k|4k} per-group config (which does apply group rate, for historical compat). All currently-supported gpt-image-* models are configured in channel pricing.
Error reference
| HTTP | Body | Meaning |
|---|---|---|
401 |
authentication_error |
Missing / invalid API key |
402 |
INSUFFICIENT_BALANCE |
Estimated cost exceeds available balance (pre-check, before queue) |
403 |
permission_error |
Reserved — currently unused for image gen |
404 |
not_found_error |
Key's group is not on an OpenAI platform (e.g. it's Anthropic / Gemini) |
413 |
request_entity_too_large |
Body > 10 MB (raise via the proxy if needed). Note some upstreams enforce a tighter 5 MB body limit that base64 images can hit first — prefer /v1/images/uploads then image_urls. |
429 |
rate_limit_error |
Gateway-level rate limit hit. Backoff. |
503 |
service_unavailable |
Image generation is not enabled on this server — the async worker isn't running; sync path still works. |
Deprecated endpoints
The following paths still work but emit Deprecation: true and Link: rel="successor-version" headers. They will be removed in a future release — migrate at your convenience.
| Deprecated | Use instead |
|---|---|
GET /v1/tasks/:id |
GET /v1/images/tasks/:id |
POST /v1/tasks/batch |
POST /v1/images/tasks/batch |
POST /v1/tasks/:id/cancel |
POST /v1/images/tasks/:id/cancel |
Picking a path — quick recipe
- OpenAI SDK / standard tools → sync
/v1/images/generations. Zero changes if you're already onopenai-pythonoropenai-node. - Codex CLI slash command → async via
/v1/images/tasks. See the Codex image gen guide. - Web UI / chat-style app with progress bar → async with
progress_image_urlpolling. - Mobile app → async, definitely.
- Background batch → async, use
/tasks/batchto poll cheaply.
Both paths share the same upstream pool and the same prices — nothing about the rendered image differs.