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-2 at high quality 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 url back via image_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 with partial_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 on openai-python or openai-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_url polling.
  • Mobile app → async, definitely.
  • Background batch → async, use /tasks/batch to poll cheaply.

Both paths share the same upstream pool and the same prices — nothing about the rendered image differs.