Files
patbond-doc/docs/development/providers/openai-gpt-image.md
T
lixi de4e02f805
CI / docs-build (push) Successful in 33s
docs: 新增 OpenAI gpt-image-2 取证页,并修火山页三处过期状态
取证页记录从两份官方文档实际读到的事实:两个端点、参数取舍、尺寸四条算术规则、
响应字段映射、错误映射、同步接口的两笔代价。

两处如实标注的不确定性:
- 尺寸上限「3840x2160」文档未说清逐边还是按像素,本页写明采用的读法与理由
  (逐边理解会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的标准竖图)。
- 两份文档都没有错误码表,故错误映射按状态码为主推断,整节挂 ⚠️ 未经证实,
  并写明拿到真实错误数据后的校正路径。

火山页三处已过期的状态:图片适配层已落地(但产物转存仍阻塞,故仍不播种模型)、
HTTP 端点已实现、Java 侧 provider 适配已实现。

mkdocs build --strict 通过。
2026-09-21 12:10:20 +08:00

248 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenAI gpt-image-2 图片生成:接口事实与映射
> 取证日期:2026-09-21 来源:用户提供的两份官方文档(`doc_temp/gpt生图.md`、
> `doc_temp/gpt图像编辑.md`)
> 实现位置:`patbond-ai` 的 `app/providers/openai/`(`client.py` 传输 / `errors.py` 错误映射 /
> `models.py` 厂商 DTO / `image.py` 图片业务 / `capabilities.py` 参数限制 /
> `tasks.py` 同步→异步桥)
本页记录**从官方文档实际读到的事实**,供实现与排查引用。凡本页与代码不一致,
以代码为准并回改本页;凡本页与厂商文档不一致,以厂商文档为准并回改两处。
## 1. 两个端点
| 用途 | 方法与路径 | 载荷形式 |
| --- | --- | --- |
| 文生图 | `POST https://api.openai.com/v1/images/generations` | JSON |
| 图生图(图像编辑) | `POST https://api.openai.com/v1/images/edits` | **multipart/form-data** |
| 鉴权 | `Authorization: Bearer $OPENAI_API_KEY` | — |
**没有第三个端点**:没有任务提交接口,也**没有任务查询接口**。两个端点都是同步
的——一次请求里等到图片生成完才返回。这一条决定了第 2、7 两节的全部内容。
## 2. 与火山视频的三处根本差异
| | 火山 Seedance(视频) | OpenAI gpt-image-2(图片) |
| --- | --- | --- |
| 调用模型 | 异步:提交返回 `{"id"}`,另有查询端点 | **同步**:一次请求等到底,无任务 ID |
| 产物形式 | URL,**有效期 24 小时** | **base64 字节**,只在内存里 |
| 图生图载荷 | JSON 里放图片 URL | **multipart**,`image` 是文件 |
三处差异的处置,逐条对应到代码:
1. **同步 → 异步**由 `tasks.py` 的 `SyncTaskRegistry` 在本服务内部吸收:`submit`
把同步调用丢进线程池、立刻返回一个本地生成的 `local-<uuid>` 任务 ID,`fetch`
查这个内存注册表。对外契约仍是 ADR-023 §4 的两段式提交/轮询,**Java 侧看不出
两家供应商的同步性差异**。代价见第 7 节。
2. **base64 产物**没有「URL 过期」问题(这比火山的 24 小时硬约束宽松),但字节
只在内存里,进程重启即丢。领域结果里放的是 `data:image/png;base64,...` 这个
data URI,**它只为满足「SUCCEEDED 必须有 asset_url」的不变式而存在,不是可长期
保存的地址**——一张 1024×1024 的 png 约 1~2MB,base64 后更大,绝不能原样写进
`media.assets.external_url`。转存落地后这里应改为返回上传后的 object key。
3. **multipart 图生图**:我们的输入是一个 URL,故需先下载再作为 multipart 上传。
下载失败按参数错误**同步拒绝**(见第 3 节末),而不是变成一个注定失败的后台
任务。字段名统一用 `image[]`(该端点支持多图且共用此名),避免单图一种写法、
多图另一种的分叉。
**另一处差异:尺寸不必查对照表。** 响应直接带 `size` 字段(`WIDTHxHEIGHT`),
故像素宽高是确定值——不像火山那边只给 `ratio` + `resolution`、需要一张随模型族
变化的对照表。
## 3. 请求参数:接了哪些、没接哪些
**接入的**(`ImageSubmission`):
| 厂商字段 | 来源 | 说明 |
| --- | --- | --- |
| `model` | 任务行冻结的快照,缺失时回落 `OPENAI_IMAGE_MODEL` | 适配层**不自行选模型**——任务记录与实际调用必须一致,否则事后无法对账 |
| `prompt` | 领域请求 | — |
| `n` | **固定 1,不开放** | 一个任务一个产物(`generation_jobs.output_asset_id` 是单值),放开 n>1 要先改数据模型 |
| `size` | `width_px`×`height_px` 拼成 `WIDTHxHEIGHT` | 两个都有才拼;只给一个时**不给 size**、交模型自选(半个尺寸不如不给) |
| `quality` | 可选 | 不传 = 服务商默认 `auto` |
| `output_format` | 可选 | `png`/`jpeg`/`webp`,默认 png |
| `background` | 可选 | `transparent` 时 `output_format` 只能 png/webp |
| `user` | 领域的 `safety_identifier` | ⚠️ **厂商字段名是 `user`**,不是 `safety_identifier`。必须传哈希,不可传明文用户 ID |
**刻意没接的**:`stream`、`partial_images`、`mask`、`input_fidelity`、
`output_compression`、`moderation`、`response_format`。理由分两类:
- `response_format` **对 GPT image 系列根本不支持**(文档原文:这些模型 always
return base64-encoded images),传了无意义。
- 其余是能力而非缺陷——多暴露一个参数,就多一处要在契约、Java 客户端、文档三处
同步维护的地方。其中 `input_fidelity: high` 对「参考图要保真」的场景有价值,
是最可能第一个被补上的,记在这里以备后用。
**参数校验发生在受理之前。** `capabilities.py` 的三个 `validate_*` 与参考图下载
都在 `registry.submit()` **之前**执行,故违规请求以 400 同步返回,而不是变成一个
注定失败的后台任务——后者要等 Java 侧一轮轮询才被发现,还白占一个线程池槽位。
## 4. 尺寸约束:四条算术规则
文档对 `size` 施加的是算术约束而非枚举(这是 gpt-image-2 与 2.5 系列共有的能力,
dall-e 系列才是固定枚举)。逐条:
| 规则 | 文档原文依据 | 代码 |
| --- | --- | --- |
| 宽高**都能被 16 整除** | width and height must both be divisible by 16 | `EDGE_MULTIPLE = 16` |
| 宽高比在 **1:3 ~ 3:1** | the requested aspect ratio must be between 1:3 and 3:1 | `MIN_ASPECT` / `MAX_ASPECT` |
| 上限 **3840x2160** | the maximum supported resolution is `3840x2160` | `MAX_EDGE` / `MAX_PIXELS` |
| 超过 **2560x1440** 属实验性 | Resolutions above `2560x1440` are experimental | 放行 + 记一条 INFO 日志 |
**标准尺寸** `1024x1024` / `1536x1024` / `1024x1536` 与 `auto` 一律直接放行,
不过算术校验:它们是文档明示支持的取值。
### ⚠️ 一处由我们推断的语义:上限是逐边还是按像素
文档那句「the maximum supported resolution is 3840x2160」**没说清**限制是逐边
(宽≤3840 **且** 高≤2160)还是按总像素。
按逐边理解会**禁掉所有高于 2160 的竖图**——而 `1024x1536` 是文档明示的标准竖图
尺寸、竖版显然受支持,故逐边理解必然过严。
采用的读法:**最长边 ≤ 3840,且总像素 ≤ 3840×2160**。它允许 `2160x3840` 这类
与横图同像素量的竖图,又拦住明显越界的尺寸。同理,「实验性」阈值也按面积理解。
拿到更明确的文档或实测结果后,回来校正 `capabilities.py` 与本节。
文档还有一句「The requested size must also satisfy the model's current pixel and
edge limits」——**没给具体数值**,故这一条无法在本地校验,只能由服务商的 400
兜住。
### 画质档按模型族分叉
`low`/`medium`/`high`/`auto` 是 GPT image 系列共有;`xhigh` 与 `max` **仅**
`gpt-image-2.5-sunburst` / `gpt-image-2.5-flare`(含其 `2026-09-08` 快照)支持。
`capabilities.py` 目前只覆盖 gpt-image-2 一张表——接入 2.5 系列时再按模型名分表,
现在分是给一个不存在的需求写代码。
## 5. 响应体的关键字段
两个端点响应同形(`ImagesResponse`):
```json
{
"created": 1713833628,
"data": [{ "b64_json": "..." }],
"size": "1024x1024",
"output_format": "png",
"quality": "high",
"usage": {
"total_tokens": 100, "input_tokens": 50, "output_tokens": 50,
"input_tokens_details": { "text_tokens": 10, "image_tokens": 40 }
}
}
```
| 字段 | 映射到领域 | 说明 |
| --- | --- | --- |
| `data[0].b64_json` | `asset_url`(data URI) | **GPT image 系列始终返回它**;`data[].url` 只用于 dall-e,对本系列不支持 |
| `size` | `width_px` / `height_px` | 直接解析,无需对照表 |
| `output_format` | `output_format` | — |
| `usage.total_tokens` | `billing_tokens` | 计费对账依据 |
| — | `asset_url_expires_in_sec` = **`None`** | 字节在手,无时效。用 `None` 而不是编一个假的秒数 |
| — | `progress` | 同步接口不提供进度:在途一律 0、完成 100。契约本不承诺平滑进度(ADR-023 §10) |
**`2xx` 但 `data` 为空当作失败处理**(`provider_code = MissingImageData`,
可退避重试)。服务商说成功却没给图,那不是成功——把它当成功会让上层拿到一个没有
产物的 SUCCEEDED 去空指针。
## 6. 错误映射 ⚠️ 未经文档证实
> **两份文档都没有错误码一节。** 这与火山那边给了完整错误码表的情况不同。
因此 `errors.py` 的映射建立在 OpenAI 通用错误约定之上(错误体形如
`{"error": {message, type, code, param}}`),并**以 HTTP 状态码为主、
`type`/`code` 为辅**。选这个策略是因为状态码语义由 HTTP 规范保证,而 `type`/`code`
的取值集没有文档支撑——凭记忆列一张表只会得到一张看起来权威、实则可能错漏的映射。
| 依据 | `retry` | 用户可见 |
| --- | --- | --- |
| `code`/`type` = `insufficient_quota` | `QUOTA` | 配额耗尽 |
| `code`/`type` = `rate_limit_exceeded` | `BACKOFF` | 限流 |
| `code` = `moderation_blocked` / `content_policy_violation` | `NEVER` | 内容被拒 |
| `code` = `invalid_api_key` | `NEVER` | 服务端错误 |
| 网络层失败(连不上、超时) | `BACKOFF` | 上游不可用 |
| HTTP 429(且拿不到 type/code) | `BACKOFF` | **按限流处理** |
| HTTP 401 / 403 | `NEVER` | 服务端错误 |
| HTTP 400 | `NEVER` | **服务端错误**,不猜成「内容被拒」 |
| 其余 4xx | `NEVER` | 服务端错误 |
| 5xx 及一切未知 | `BACKOFF` | 上游不可用 |
两处取舍值得记下来:
- **429 拿不到 type/code 时按限流而非配额耗尽。** 429 在 OpenAI 上两义兼有;猜成
配额耗尽会把一次本可重试的限流判死,代价更大。
- **400 归「服务端错误」而不是「内容被拒」。** OpenAI 用 400 同时表示参数问题与
内容被拒;拿不到 type/code 时无法区分,而错报「内容被拒」会让用户去改一个其实
没问题的提示词。
未知情形一律偏向**可退避重试**:把一次可恢复的抖动判成用户可见的失败是更糟的
错误,而重试预算本身有上限(`max_attempts`)。
**待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,校正 `errors.py`
并回改本节。在那之前,`errors.py` 顶部那段 ⚠️ 说明不要删。
## 7. 同步接口的两笔代价
把同步接口包成异步是有代价的,且这两笔代价**无法靠代码消除**,只能明写出来。
### ① `patbond-ai` 从此持有状态
`SyncTaskRegistry` 是本服务**唯一**的有状态组件。后果:
- **进程重启会丢掉所有在途任务。** 重启后 Java 侧轮询该任务 ID 得到
`task_not_found`,按失败处理。这与「任务 ID 从未存在过」无法区分,故代码不假装
区分——两种情形下调用方要做的是同一件事:停止轮询、任务判失败。
- **`patbond-ai` 因此不能随意多副本部署。** 任务 ID 只在签发它的那个进程里有效,
一旦多副本 + 无粘性路由,轮询会落到不认识该 ID 的副本上。当前单机部署无此问题,
**但横向扩容前必须先解决它**(把注册表外移到共享存储,或在网关做按 ID 的粘性
路由)。视频那侧没有这个约束——火山的任务 ID 是全局的,任何副本都能查。
- 内存有界:`OPENAI_MAX_INFLIGHT`(默认 256)满了以后**拒绝新提交**(503),
而不是挤掉在途任务——挤掉一个在途任务会让一次可能正在计费的调用变成无人认领的
开销。已完成条目按 `OPENAI_COMPLETED_TTL_SEC` 过期,**在途条目永不过期**。
### ② 超时窗口无法靠「再查一次」弥补
`OPENAI_TIMEOUT_SEC` 的语义与 `ARK_TIMEOUT_SEC` **不同**,这一点最容易配错:
| | `ARK_TIMEOUT_SEC` | `OPENAI_TIMEOUT_SEC` |
| --- | --- | --- |
| 含义 | 单次 HTTP 短往返(提交只入队即返回) | **生成耗时的上限** |
| 默认 | 10 秒 | 180 秒 |
请求超时时,**那次调用可能已经在服务商侧完成并计费**,而我们拿不到结果。图片接口
没有任务 ID 可供事后查询,故这个窗口**无法**像火山那样靠重查一次弥补——只能由
`max_attempts` 兜住上限。这是选用同步型供应商的固有成本,不是实现缺陷。
### 一处已修的死锁,记下来避免重犯
`SyncTaskRegistry` 起初用 `add_done_callback` 记录完成时刻。这会**死锁**:快速完成
的任务在 `add_done_callback` 时已经 done,回调便**同步地在调用线程执行**,而该线程
此刻正持有注册表的锁(非可重入)。现在的做法是——完成时刻在**首次被观察到**时
惰性记录(`fetch` 与过期清理里)。代价是 TTL 起算点可能略晚于实际完成时刻,
这对「结果被取走前不要清掉」这个目的无害,反而更保守。
## 8. 实现状态
`app/providers/openai/` 与火山适配层**刻意不共用传输层**:两家的错误体形状不同
(OpenAI 是 `{"error": {message, type, code, param}}`,火山是 `{"error": {code, message}}`),
且 OpenAI 的编辑端点走 multipart。强行抽一个共同基类会让两边都被对方的特例污染
——供应商适配层之间**不该**互相复用,各自复用自家的传输层才是分层的本意。分层约束
由 `tests/test_layering.py` 用 AST 自动守着。
| 项 | 状态 |
| --- | --- |
| HTTP 传输与鉴权(`client.py`) | ✅ 离线测试(MockTransport) |
| 错误分类(`errors.py`) | ✅ 有测试,但**映射本身未经文档证实**(第 6 节) |
| 参数预校验(`capabilities.py`) | ✅ 四条算术规则 + 画质/格式 |
| 同步→异步桥(`tasks.py`) | ✅ 含容量上限、TTL、死锁回归 |
| 图片业务(`image.py`) | ✅ 文生图 + 图生图(含参考图下载) |
| 装配与配置(`assembly.py` / `config.py` / `.env.sample`) | ✅ 缺 `OPENAI_API_KEY` 时跳过装配,服务仍可启动 |
| 产物转存 | ⏸ 阻塞于存储决策(ADR-023 §7)。注意 base64 字节只在内存里 |
| V7 目录种子(图片模型) | ⏸ 未播种——播一个没有转存能力的模型会让客户端出现点了没反应的入口 |
| Java 侧图片 provider 路由 | ⏸ 待上一项 |
**跨语言约定,两侧都要对齐**:目录表的 `provider_code` 必须与适配器的
`PROVIDER_CODE` **逐字一致**(`openai-gpt-image`)。对不上时 Worker 按任务行冻结的
快照查不到适配器,任务会永远停在排队——而那看起来像队列坏了,不像两个字符串不
一致。播种图片模型时须同时加上这条一致性断言(火山那侧已有)。