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

18 KiB
Raw Blame History

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):

{
  "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 按任务行冻结的 快照查不到适配器,任务会永远停在排队——而那看起来像队列坏了,不像两个字符串不 一致。播种图片模型时须同时加上这条一致性断言(火山那侧已有)。