feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
CI / ai-gates (push) Successful in 38s

新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、
models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。
装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。

与火山视频的三处根本差异及其处置:
- 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收,
  对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启
  丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。
- 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放
  data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。
- 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝,
  不变成注定失败的后台任务。

两处需要记住的判断:
- 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于
  2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边
  ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。
- 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅,
  顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可
  重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改
  一个其实没问题的提示词)。

一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻,
而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。
改为首次被观察到时惰性记录。

测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。
产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
This commit is contained in:
2026-09-21 12:10:07 +08:00
parent 062e11b1ff
commit 8275a04903
21 changed files with 2375 additions and 11 deletions
+30 -1
View File
@@ -32,6 +32,18 @@ info:
`service` 类属我方 bug 或配置问题(除 `internal_error`/`provider_unavailable`
外均不可重试);`provider` 类一律以 `retry` 字段为准,不要自己解读厂商码。
## 一处与服务商形态有关的说明
**服务商可能是同步的。** 火山的视频接口本身异步(提交返回任务 ID);OpenAI 的
图片接口是**同步**的——一次请求等到底,没有任务 ID 也没有查询接口。该差异由本
服务在内部吸收(ADR-023 §4),故本契约对两者形状完全一致。
代价如实登记:同步型供应商的在途任务只能记在本服务内存里,**进程重启会丢失**,
之后查询返回 `task_not_found`。那次调用可能已经完成并计费,而同步接口没有任务
ID 可供事后查询,故该窗口**无法靠"再查一次"弥补**,只能由调用方的
`max_attempts` 兜住。并发在途任务达上限时提交返回 `provider_unavailable`
(503,可重试)而非 `invalid_request`。
## 鉴权
所有端点要求服务身份 JWT:`Authorization: Bearer <token>`。本服务强制校验
@@ -192,8 +204,25 @@ components:
resolution:
type: [string, 'null']
description: |
缺省 `720p`。按模型预校验:2.5 无 4k,2.0 系列有。
缺省 `720p`。按模型预校验:2.5 无 4k,2.0 系列有。**视频路径专用**。
example: 720p
width_px:
type: [integer, 'null']
description: |
**图片路径专用**的像素宽。与 `height_px` 必须同时给,否则交由模型自选
(半个尺寸不如不给)。
两家服务商的尺寸表达方式不同:火山用「分辨率档 + 宽高比」
(`resolution` + `ratio`),OpenAI 用 `WIDTHxHEIGHT` 像素串。契约同时
保留两种而**不强行统一**——任何统一都会在某一侧失真:把 720p+16:9 折成
像素会丢掉「档位」语义,把像素折成比例会丢掉精确值。
OpenAI 侧的算术约束会在本服务**本地预校验**:宽高都须被 16 整除、
宽高比在 1:3 ~ 3:1 之间、不超过 3840x2160(超过 2560x1440 属服务商
标注的实验性范围)。违规请求以 `invalid_request` 拒绝,不花一次往返。
height_px:
type: [integer, 'null']
description: 见 `width_px`。
duration_sec:
type: [integer, 'null']
description: |