cc538e827f
CI / docs-build (push) Successful in 39s
新增三部分:此前猜的五个码名与官方对照(六错五,作为教训留档)、官方表 13 条的 分类一览(全是基础设施与计费,**没有任何内容审核/侵权码**)、逐条抄自文档的映射表。 两处标注清楚的判断:400 的两义用 error.param 的有无区分(推断,附三条依据与取错 代价的不对称分析);Retry-After 暂不接(附可算的理由——Java 侧退避基线已长于服务商 通常给的值)。 另记一条厂商改判:视频过载已从 429 改判为 503,我们两者都归可重试故不受影响, 并说明测试在哪里钉住这个「无影响」。 mkdocs build --strict 通过。
315 lines
18 KiB
Markdown
315 lines
18 KiB
Markdown
# 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 按任务行冻结的
|
||
快照查不到适配器,任务会永远停在排队——而那看起来像队列坏了,不像两个字符串不
|
||
一致。播种图片模型时须同时加上这条一致性断言(火山那侧已有)。
|