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
+43 -7
View File
@@ -71,7 +71,7 @@ python -m app # 默认 127.0.0.1:8000
## 代码结构
分四层,依赖**单向**向内(`config` → `providers` → `domain`),由
分层,依赖**单向**向内(`api` → `assembly`/`config` → `providers` → `domain`),由
`tests/test_layering.py` 自动守着——靠纪律守不住的东西,靠测试才守得住。
```
@@ -80,11 +80,21 @@ app/
├── providers/
│ ├── base.py 生成能力抽象(新增供应商 = 实现它)
│ ├── registry.py provider_code → 适配器
│ └── volcengine/
│ ├── client.py HTTP 传输与鉴权(不认识「视频」)
│ ├── errors.py 火山错误码 → 领域 Failure
│ ├── volcengine/
│ │ ├── client.py HTTP 传输与鉴权(不认识「视频」)
│ │ ├── errors.py 火山错误码 → 领域 Failure
│ │ ├── models.py 厂商请求/响应 DTO
│ │ ├── capabilities.py 模型能力与参数限制
│ │ ├── dimensions.py ratio+resolution → 像素对照表
│ │ └── video.py Seedance 视频业务
│ └── openai/
│ ├── client.py HTTP 传输与鉴权(JSON + multipart)
│ ├── errors.py OpenAI 错误 → 领域 Failure
│ ├── models.py 厂商请求/响应 DTO
│ └── video.py Seedance 视频业务
│ ├── capabilities.py 尺寸算术约束与画质档
│ ├── tasks.py 同步→异步桥(本服务唯一的有状态组件)
│ └── image.py gpt-image-2 图片业务(文生图 + 图生图)
├── api/ HTTP 端点、服务票校验、错误信封
├── config.py 集中配置(全部旋钮从环境变量读)
└── assembly.py 装配根(唯一同时知道配置与具体供应商的地方)
```
@@ -98,6 +108,10 @@ app/
- **`client.py` 与 `video.py` 分开。** client 只管鉴权、超时、错误解包,不认识
「视频」。以后接火山的**图片或语音**接口时新建兄弟模块复用同一个 client,
而不是再抄一遍鉴权与错误解包——这是拆它出来的全部理由。
- **两家的 `client.py` 刻意不共用。** 错误体形状不同(OpenAI 是
`{"error": {message, type, code, param}}`,火山是 `{"error": {code, message}}`),
且 OpenAI 的编辑端点走 multipart。抽一个共同基类会让两边都被对方的特例污染
——**同一家的不同能力之间才复用传输层,不同供应商之间不复用**。
- **适配层不读配置**,全部通过构造参数注入。否则单测得摆弄环境变量,且同一进程
内没法跑两套不同配置的实例。
@@ -116,12 +130,28 @@ app/
| 服务商 | 能力 | provider_code | 状态 |
| --- | --- | --- | --- |
| 火山方舟 Seedance | 视频生成 | `volcengine-seedance` | 提交与查询已实现,产物转存待存储决策 |
| OpenAI gpt-image-2 | 图片生成(文生图 / 图生图) | `openai-gpt-image` | 同上;另见下方「同步型供应商的代价」 |
接口事实与错误码映射的完整取证见 patbond-doc
`docs/development/providers/volcengine-seedance.md`。其中两条尤其要记住:
`docs/development/providers/volcengine-seedance.md` 与
`docs/development/providers/openai-gpt-image.md`。其中几条尤其要记住:
- **火山不支持幂等键**,「提交超时后不知道有没有扣费」只能由 `max_attempts` 兜住;
- **产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存。
- **火山产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存;
- **OpenAI 的两份文档都没有错误码表**,故 `providers/openai/errors.py` 的映射是
按 HTTP 状态码为主推断的,**未经文档证实**,拿到真实错误数据后须校正。
### 同步型供应商的代价
OpenAI 的图片接口是**同步**的(没有任务 ID,也没有查询端点),由
`providers/openai/tasks.py` 在本服务内部包成异步。两笔代价无法靠代码消除:
- **本服务从此持有状态。** 进程重启会丢掉所有在途图片任务(Java 侧轮询得到
`task_not_found`,按失败处理)。也因此 **`patbond-ai` 横向扩容前必须先解决
任务 ID 只在签发进程内有效的问题**——多副本 + 无粘性路由会让轮询落到不认识
该 ID 的副本上。视频那侧没有这个约束,火山的任务 ID 是全局的。
- **超时窗口无法靠重查弥补。** 超时那次调用可能已在服务商侧完成并计费,而图片
接口没有任务 ID 可供事后查询。只能由 `max_attempts` 兜住。
## 配置
@@ -134,6 +164,12 @@ app/
| `ARK_TIMEOUT_SEC` | `10` | **单次 HTTP 往返**超时,不是生成耗时 |
| `ARK_VIDEO_MODEL` | _(无)_ | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
| `ARK_TASK_EXPIRY_SEC` | `3600` | 厂商 `execution_expires_after`,区间 `[3600, 259200]`。默认取下限——厂商默认 48 小时对交互式应用过长 |
| `OPENAI_API_KEY` | _(无)_ | OpenAI 密钥。**缺失时该适配器不装配**,服务仍可启动 |
| `OPENAI_TIMEOUT_SEC` | `180` | ⚠️ 与 `ARK_TIMEOUT_SEC` **语义不同**:图片接口同步,这里是**生成耗时的上限** |
| `OPENAI_IMAGE_MODEL` | _(无)_ | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
| `OPENAI_MAX_CONCURRENT` | `4` | 同时执行的同步调用数上限,也是「同时有多少钱在花」的上界 |
| `OPENAI_COMPLETED_TTL_SEC` | `900` | 已完成任务在内存注册表的保留时长。**须大于 Java 侧最长轮询间隔**,否则结果会在被取走前被清掉 |
| `OPENAI_MAX_INFLIGHT` | `256` | 注册表条目上限。满了拒绝新提交(503),**不挤掉在途任务** |
| `PATBOND_SERVICE_JWT_PUBLIC_KEY` | _(无)_ | 校验 Java 侧服务票的 RS256 公钥 |
| `PATBOND_SERVICE_JWT_AUDIENCE` | `patbond-ai` | **必须强制校验**,否则独立密钥对失去意义 |