Asynchronous Image Tasks

July 19, 2026 · View on GitHub

Asynchronous image tasks let clients submit long-running OpenAI-compatible image requests without keeping one HTTP connection open. This avoids proxy/CDN response timeouts such as Cloudflare 524 while preserving the existing image routing, billing, moderation, concurrency, and failover behavior.

Endpoints

The authenticated gateway exposes both /v1 paths and their existing no-prefix aliases:

POST /v1/images/generations/async
POST /v1/images/edits/async
GET  /v1/images/tasks/{task_id}

The aliases are /images/generations/async, /images/edits/async, and /images/tasks/{task_id}.

Only OpenAI and Grok groups are supported. Requests use the same JSON or multipart payload as the corresponding synchronous endpoint. Streaming image requests are rejected because a polled task returns one final JSON result.

Enabling the feature (object storage)

Asynchronous image tasks are disabled by default and gated on object storage. When the switch is off — or the S3 credentials are incomplete — the async endpoints return 404 and never create a task or write to Redis. This is deliberate: without offloading, large b64_json results (several MB each, e.g. gpt-image-1) would accumulate in Redis and exhaust its memory.

Admin → Backup → Async image object storage. Saving the form takes effect immediately — the object-storage client is rebuilt on the next request, so there is no container restart.

Because the async image storage and the database backup share one S3 client, the form defaults to reusing the backup S3 configuration: it borrows the endpoint, region and credentials already configured above and keeps only its own bucket and prefix, so backups stay under backups/ while images go to images/. Leave the bucket empty to use the backup bucket as well. Untick the box to point images at a completely separate account.

Saving requires step-up 2FA when that gate is enabled, for the same reason the backup S3 form does: changing the target redirects generated content to another account.

Turning the switch off stops new submissions but keeps already-accepted tasks pollable, so nothing in flight is stranded.

From the config file

The admin setting takes precedence. When nothing has ever been saved there, the image_storage block in config.yaml is used instead, so deployments that enabled the feature before the admin UI existed keep working untouched.

Configure an S3-compatible object store (AWS S3, Cloudflare R2, Aliyun OSS, MinIO, …) in config.yaml (all keys also accept the IMAGE_STORAGE_* environment overrides):

image_storage:
  enabled: true
  endpoint: "https://<account_id>.r2.cloudflarestorage.com"  # AWS 官方可留空
  region: "auto"
  bucket: "my-images"
  access_key_id: "..."
  secret_access_key: "..."
  prefix: "images/"
  force_path_style: false          # MinIO/path-style buckets set true
  public_base_url: ""              # set to return public_base_url/key直链; empty → presigned URL
  presign_expiry_hours: 24         # presigned link TTL when public_base_url is empty
  max_download_bytes: 33554432     # cap when re-hosting an upstream image URL (32MB)

When a task completes, each generated image is uploaded to the bucket and the result is rewritten to a compact form: data[].url points at the stored object (a permanent public_base_url/key link, or a time-limited presigned URL) and b64_json is removed. Only this small JSON is stored in Redis. If an upload fails, the task is marked failed rather than persisting the raw base64.

To support a different vendor beyond the S3-compatible client, implement the service.ImageStorage interface (Save(ctx, key, contentType, data) (url, error)) and provide it in place of the S3 implementation.

Troubleshooting: the endpoints return 404 after enabling

404 async image tasks are not enabled means image_storage did not resolve to a complete configuration, so the feature stayed off. The route exists either way — the 404 comes from the handler, not from an unregistered path, which makes it easy to mistake for a missing build.

Check the startup log for:

WARN image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled  missing_keys=[...]

missing_keys names exactly which credentials were empty when the config was loaded.

Note that releases before v0.1.161 silently dropped IMAGE_STORAGE_ENDPOINT, _BUCKET, _ACCESS_KEY_ID, _SECRET_ACCESS_KEY and _PUBLIC_BASE_URL when they were supplied only through the environment: those keys had no registered default, and viper cannot see an environment variable for a key it does not already know about. Deployments driven purely by environment: — which is what deploy/docker-compose.yml does by default — therefore reported enabled: true with empty credentials and 404'd on every async call. On an affected release the workaround is to also place the image_storage block in /app/data/config.yaml (copy it from deploy/config.example.yaml); once the keys exist in the file, the environment overrides apply normally.

Two further causes of a 404 that are unrelated to storage: the API key's group must be on the OpenAI or Grok platform (any other platform, or a key with no group at all, yields Images API is not supported for this platform), and a task may only be polled with the same API key that submitted it — polling with a different key of the same user returns image task not found by design.

Submit a task

curl -i https://api.example.com/v1/images/generations/async \
  -H 'Authorization: Bearer sk-...' \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "gpt-image-1",
    "prompt": "A lighthouse during a winter storm",
    "size": "1536x1024"
  }'

The server stores the initial task in Redis and responds with 202 Accepted:

{
  "id": "imgtask_0123456789abcdef",
  "task_id": "imgtask_0123456789abcdef",
  "object": "image.generation.task",
  "status": "processing",
  "created_at": 1784092800,
  "expires_at": 1784179200,
  "poll_url": "/v1/images/tasks/imgtask_0123456789abcdef"
}

Location contains the polling path and Retry-After: 3 provides the recommended polling interval.

Poll a task

Use the same API key that submitted the task:

curl https://api.example.com/v1/images/tasks/imgtask_0123456789abcdef \
  -H 'Authorization: Bearer sk-...'

While work is in progress:

{
  "id": "imgtask_0123456789abcdef",
  "task_id": "imgtask_0123456789abcdef",
  "object": "image.generation.task",
  "status": "processing",
  "created_at": 1784092800,
  "expires_at": 1784179200
}

On success, result mirrors the synchronous image API body, except each image has been offloaded to object storage: data[].url points at the stored object and b64_json is stripped (so both URL and base64 upstream formats end up as compact stored links):

{
  "id": "imgtask_0123456789abcdef",
  "task_id": "imgtask_0123456789abcdef",
  "object": "image.generation.task",
  "status": "completed",
  "http_status": 200,
  "image_url": "https://...",
  "result": {
    "created": 1784092923,
    "data": [{"url": "https://..."}]
  },
  "created_at": 1784092800,
  "completed_at": 1784092923,
  "expires_at": 1784179323
}

For URL responses, image_url mirrors the first data[].url for simple clients. On failure, the task reaches failed and exposes the original OpenAI-compatible error object where available:

{
  "id": "imgtask_0123456789abcdef",
  "task_id": "imgtask_0123456789abcdef",
  "object": "image.generation.task",
  "status": "failed",
  "http_status": 502,
  "error": {
    "type": "api_error",
    "message": "Upstream request failed"
  },
  "created_at": 1784092800,
  "completed_at": 1784092923,
  "expires_at": 1784179323
}

All submit and poll responses include Cache-Control: no-store, preventing a CDN from caching the processing state. Tasks and results expire 24 hours after their latest state update. A task executes for at most 30 minutes.

Task ownership is scoped to both user and API key. Unknown task IDs and IDs owned by another key both return 404, avoiding task-existence disclosure. Polling remains available when the completed generation used the key's remaining balance; normal authentication, disabled-key, user, IP, and group checks still apply.