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
+149
View File
@@ -0,0 +1,149 @@
"""OpenAI 图片接口的请求/响应 DTO。
只放**厂商词汇**。领域概念在 `app/domain/generation.py`,翻译在 `image.py`。
一处值得注意的厂商差异:同一份请求体在**文生图走 JSON、图生图走 multipart**
(后者的 `image` 是文件)。故本类提供两种输出形态——`to_json_body()` 与
`to_form_fields()`。multipart 的字段值必须是字符串,布尔与数字要转,不能直接塞。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ImageSubmission:
"""一次图片生成请求。
刻意只暴露 Patbond 会用到的参数。厂商参数远多于此(`n`、`partial_images`、
`stream`、`mask`、`input_fidelity`、`output_compression`、`moderation`…),
多暴露一个就多一处要在契约、客户端、文档三处同步维护的地方。
:param n: **固定为 1,不开放**。领域模型的一个任务对应一个产物
(`generation_jobs.output_asset_id` 是单值);允许 n>1 就得改数据模型。
:param quality: `None` 交给服务商默认(auto)。注意 `xhigh`/`max` 仅
gpt-image-2.5 系列支持,gpt-image-2 传了会被拒(`capabilities` 会先拦)。
:param background: `transparent` 时 `output_format` 只能 png/webp。
文档称 gpt-image-2 对透明背景的支持**处于预览**,故默认不指定。
:param safety_identifier: 对应厂商的 `user` 字段——终端用户标识,供其做滥用
检测。必须是哈希,不可传明文用户 ID。
"""
model: str
prompt: str
size: str | None = None
quality: str | None = None
output_format: str | None = None
background: str | None = None
safety_identifier: str | None = None
def to_json_body(self) -> dict[str, Any]:
"""文生图(`/images/generations`)的 JSON 体。"""
body: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
# 一个任务一个产物,见类 docstring。
"n": 1,
}
self._add_optional(body)
return body
def to_form_fields(self) -> dict[str, str]:
"""图生图(`/images/edits`)的 multipart 文本字段。
multipart 的字段值必须是字符串——数字与布尔直接塞进去会被 httpx 拒绝或
编码成意外形态。
"""
fields: dict[str, Any] = {
"model": self.model,
"prompt": self.prompt,
"n": 1,
}
self._add_optional(fields)
return {key: str(value) for key, value in fields.items()}
def _add_optional(self, target: dict[str, Any]) -> None:
"""只加已设置的可选项。
不设的字段**留空而不显式传 null**:服务商对每个参数都有文档化的默认值,
显式 null 可能覆盖掉那些默认值。
"""
if self.size:
target["size"] = self.size
if self.quality:
target["quality"] = self.quality
if self.output_format:
target["output_format"] = self.output_format
if self.background:
target["background"] = self.background
if self.safety_identifier:
# 厂商字段名是 `user`,不是 safety_identifier。
target["user"] = self.safety_identifier
@dataclass(frozen=True)
class GeneratedImage:
"""`data[]` 里的一张图。
:param b64_json: base64 编码的图片。**GPT image 系列始终返回它**
(`response_format` 对这些模型不支持,`url` 只用于 dall-e)。
"""
b64_json: str | None = None
url: str | None = None
revised_prompt: str | None = None
@dataclass(frozen=True)
class ImagesResponse:
"""`/images/generations` 与 `/images/edits` 的共同响应形状。
:param size: `WIDTHxHEIGHT` 字符串。**像素宽高由此直接得到**,不像火山那边
只给 ratio+resolution、需要一张随模型族变化的对照表。
:param total_tokens: 计费对账依据(`usage.total_tokens`)。
"""
created: int | None = None
data: tuple[GeneratedImage, ...] = ()
size: str | None = None
output_format: str | None = None
quality: str | None = None
background: str | None = None
total_tokens: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
_raw: dict[str, Any] | None = None
def first_image(self) -> GeneratedImage | None:
return self.data[0] if self.data else None
def raw_keys(self) -> list[str]:
"""原始响应的顶层键,仅用于「2xx 但没有图」时的日志。"""
return sorted(self._raw) if self._raw else []
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> ImagesResponse:
usage = payload.get("usage") or {}
images = tuple(
GeneratedImage(
b64_json=item.get("b64_json"),
url=item.get("url"),
revised_prompt=item.get("revised_prompt"),
)
for item in (payload.get("data") or [])
if isinstance(item, dict)
)
return cls(
created=payload.get("created"),
data=images,
size=payload.get("size"),
output_format=payload.get("output_format"),
quality=payload.get("quality"),
background=payload.get("background"),
total_tokens=usage.get("total_tokens"),
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
_raw=payload,
)