Files
sub2api/docs/ASYNC_IMAGE_TASKS.md
T
harukaandClaude Opus 4.8 37db8d031b fix(config): 让环境变量能真正配置 image_storage 等凭证
viper.Unmarshal 只解码 AllKeys() 返回的键,而 AllKeys() 只汇总 SetDefault、
配置文件和显式 BindEnv 三个来源。AutomaticEnv 仅能覆盖已在其中的键,无法引入
新键;能兜底的 viper_bind_struct 又被 build tag 排除(我们只用 -tags embed)。

因此任何「没有注册默认值、且不在 config.yaml 里」的配置项,其环境变量会被静默
丢弃。image_storage 的 endpoint/bucket/access_key_id/secret_access_key/
public_base_url 正属此列,于是纯环境变量部署落到最坏组合:IMAGE_STORAGE_ENABLED
生效使 Enabled=true,四个凭证却为空 → Active()=false → 异步生图接口整体 404,
运维看到的却是"凭证不完整"。deploy/docker-compose.yml 默认就是纯环境变量驱动,
且自动生成的 config.yaml 从不写 image_storage 段,必然踩中(见 #4458、#4542)。

同类缺口不止于此:github_oauth、google_oauth、dingtalk_connect 三组第三方登录
配置(含 client_secret)同样完全无法用环境变量设置。

- 为这些键注册零值默认,使其进入 AllKeys() 而可被环境变量覆盖。零值与"键缺失"
  时的解码结果一致,故行为不变。
- sticky_escape_enabled 例外:它的实际默认是 true(靠 IsSet 守卫在解码后补上),
  注册 false 会让 IsSet 恒真而永久关闭该特性,故直接注册 true。
- 启动告警补上 missing_keys 字段,指明到底哪个凭证为空。
- 新增反射守卫测试:Config 结构体上每个可由环境变量表达的字段都必须已注册默认值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
2026-07-18 05:24:32 -07:00

7.1 KiB

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:

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.

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.