Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
167 lines
8.3 KiB
Markdown
167 lines
8.3 KiB
Markdown
# Asynchronous Image Tasks
|
|
|
|
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:
|
|
|
|
```text
|
|
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.
|
|
|
|
### From the admin UI (recommended)
|
|
|
|
**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):
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```text
|
|
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
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```bash
|
|
curl https://api.example.com/v1/images/tasks/imgtask_0123456789abcdef \
|
|
-H 'Authorization: Bearer sk-...'
|
|
```
|
|
|
|
While work is in progress:
|
|
|
|
```json
|
|
{
|
|
"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):
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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.
|