Files
patbond-doc/docs/development/providers/openai-gpt-image.md
T
lixi cc538e827f
CI / docs-build (push) Successful in 39s
docs: gpt-image-2 取证页错误映射一节按官方 Error codes 页重写
新增三部分:此前猜的五个码名与官方对照(六错五,作为教训留档)、官方表 13 条的
分类一览(全是基础设施与计费,**没有任何内容审核/侵权码**)、逐条抄自文档的映射表。

两处标注清楚的判断:400 的两义用 error.param 的有无区分(推断,附三条依据与取错
代价的不对称分析);Retry-After 暂不接(附可算的理由——Java 侧退避基线已长于服务商
通常给的值)。

另记一条厂商改判:视频过载已从 429 改判为 503,我们两者都归可重试故不受影响,
并说明测试在哪里钉住这个「无影响」。

mkdocs build --strict 通过。
2026-09-21 15:52:41 +08:00

315 lines
18 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`、`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. 错误映射
> 取证补充(2026-09-21):收到官方 Error codes 页(`doc_temp/gpt错误码.md`)。
> 本节此前是按 HTTP 状态码推断的版本,现已按文档实际码名重写。
### 先记一笔教训:此前猜的五个码名,一个都不存在
| 我猜的 | 官方文档里 |
| --- | --- |
| `rate_limit_exceeded` | ❌ 不存在。真实的是 type `rate_limit_error` + code `slow_down` |
| `moderation_blocked` | ❌ 不存在 |
| `content_policy_violation` | ❌ 不存在 |
| `invalid_api_key` | ❌ 不存在(401 只给人类可读消息,无 code) |
| `image_generation_user_error` | ❌ 不存在 |
| `insufficient_quota` | ✅ 存在,但它是**宽 `type`** 而非 `code` |
六个里错五个。这就是「凭记忆列错误码表」的实际命中率——那张表当时看起来是权威的。
故当时的 ⚠️ 标注不是客套:**没有文档就要明写没有文档**。
### ⚠️ 官方错误码表里没有任何业务层失败
13 条全是**基础设施与计费**:
| 类别 | 条目 |
| --- | --- |
| 鉴权(401) | 无效认证 / 密钥不正确 / 不属于任何组织 / IP 不在白名单 |
| 地域(403) | 国家、地区或领地不受支持 |
| 参数(400) | `service_tier` 不被项目允许 |
| 限流(429) | 请求过快 / `slow_down`(涨得太快) |
| 钱(429) | 余额耗尽 / 组织消费限额 / 项目消费限额 / 组织用量限额 |
| 服务端(500 / 503) | 内部错误 / 模型过载 |
**没有内容审核、没有版权/侵权、没有「这个提示词不能生成」。** 但图片接口明确有
`moderation` 参数(`low`/`auto`),说明审核确实存在——只是它的错误形态未被文档化。
### 映射表(键全部抄自文档)
| 依据 | `retry` | 用户可见 |
| --- | --- | --- |
| `credit_balance_exhausted` | `QUOTA` | 配额耗尽 |
| `organization_spend_limit_exceeded` | `QUOTA` | 配额耗尽 |
| `project_spend_limit_exceeded` | `QUOTA` | 配额耗尽 |
| `organization_usage_limit_exceeded` | `QUOTA` | 配额耗尽 |
| `insufficient_quota`(宽 type) | `QUOTA` | 配额耗尽 |
| `slow_down` / `rate_limit_error` | `BACKOFF` | 限流 |
| `server_is_overloaded` / `service_unavailable_error` | `BACKOFF` | 上游不可用 |
| HTTP 429(无 code 且 type 不认识) | `BACKOFF` | 限流 |
| HTTP 400 **带** `error.param` | `NEVER` | 服务端错误 |
| HTTP 400 **不带** `error.param` | `NEVER` | **内容被拒**(推断,见下) |
| HTTP 401 / 403 | `NEVER` | 服务端错误 |
| 其余 4xx | `NEVER` | 服务端错误 |
| 网络层失败(连不上、超时) | `BACKOFF` | 上游不可用 |
| 5xx 及一切未知 | `BACKOFF` | 上游不可用 |
`Retry.QUOTA` 这一档的存在理由正是文档那句原话:**「Retrying billing, spend, or
quota errors won't restore API access」**——重试不会恢复访问,要人去充值或改限额。
Worker 不该把重试预算花在这上面,应直接终态失败并让用户看到出口。
**两个字段查同一张表。** 文档把某个标识列在哪个字段下,不保证实际响应里它一定出现
在那个字段(`insufficient_quota` 被描述为「broader `error.type`」,但很容易出现在
`code` 里)。查错字段的代价是把配额耗尽降级成限流、白烧一轮重试预算;而两边都查
不引入任何**新**词汇。
### 唯一的推断:400 的两义如何区分
因为没有内容审核码,**一次侵权或违规拒绝很可能以一个普通 400 到达**,只带一句
人类可读的消息。若把这种 400 一律归为「服务端错误」,用户会看到「服务出错了」
然后反复重试同一个注定被拒的提示词。
用 `error.param` 的有无做结构化区分,三条依据:
1. 文档在 `service_tier` 那条明确写了「as an `invalid_request_error` with
`error.param` set to `service_tier`」——**参数类错误会带 `param`**。
2. 官方表没有任何内容审核码,故内容拒绝只能以通用 400 到达,且它不指向任何具体
参数,`param` 应为空。
3. 我方在调用前已用 `capabilities.py` 校验过尺寸、画质、格式,`model`/`n` 由程序
生成——**能活着到达服务商并被 400 拒掉的,参数嫌疑本来就不大**。
取错的代价不对称,这也是选这个方向的理由:把内容拒绝报成「服务出错」会让用户反复
重试一个注定被拒的提示词;反过来把一个参数 bug 报成「内容可能不被支持」只是让用户
多试一次,而排查者看到的 `provider_code` 与日志仍然准确。走这条分支时会记一条
**WARNING**,那是日后校正它的唯一素材来源。
**待补**:拿到真实的审核错误形态(或官方补出业务码)后,把这里换成确定的判定。
### 一处厂商改判,对我们无影响
文档提醒:**视频过载已从 429 改判为 503**,且 Python SDK 分别抛 `RateLimitError`
与 `InternalServerError`,只捕一类会漏。我们两者都归可重试,故这次改判不影响本
服务——`tests/providers/openai/test_errors.py` 里有一条测试专门钉住这个「无影响」,
哪天有人把 5xx 改成不可重试,它会红。
### 未接的一项:`Retry-After`
文档在 429 与 503 处反复要求遵守 `Retry-After` 响应头。本服务目前**不读它**。
不读的理由是可算的:Java 侧 Worker 的退避阶梯基线 30 秒、指数增长、上限 10 分钟
(`CreationProperties.retryBackoffFor`),**在绝大多数情形下已长于服务商给的
`Retry-After`**——也就是我们已经比要求更保守,忽略它只会让重试偏慢,不会加剧限流。
要接起来是一次跨语言契约变更:领域 `Failure` 加字段 → `openapi-internal.yaml` 小
版本 → Java 侧 DTO 与 Worker 调度都要改。收益只是延迟优化,故排在存储决策之后。
## 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`) | ✅ 按官方 Error codes 页逐条落地;仅 400 的两义区分是推断(第 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 按任务行冻结的
快照查不到适配器,任务会永远停在排队——而那看起来像队列坏了,不像两个字符串不
一致。播种图片模型时须同时加上这条一致性断言(火山那侧已有)。