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
+253
View File
@@ -0,0 +1,253 @@
"""gpt-image-2 图片生成:文生图与图生图(图像编辑)。
## 与火山视频适配器的三处根本差异
1. **接口是同步的。** `POST /images/generations` 一次请求等到底,**没有任务 ID、
没有查询接口**。差异由 `tasks.SyncTaskRegistry` 在本服务内部吸收(ADR-023 §4),
代价与风险见该模块顶部说明。
2. **产物是 base64 字节,不是 URL。** GPT image 系列**始终**返回 base64
(`response_format` 对它们不支持)。好处是没有「URL 24 小时过期」问题;坏处是
字节只在内存里,必须立刻转存——而转存阻塞于存储决策(ADR-023 §7)。
3. **图生图走 multipart,不是 JSON。** `/images/edits` 的 `image` 字段是文件。
我们的输入是一个 URL,故需先把它下载下来再作为 multipart 上传。
## 尺寸从响应直接拿,不需要对照表
响应带 `size` 字段(`WIDTHxHEIGHT`),故像素宽高是确定值——不像火山那边只给
`ratio`+`resolution`、需要一张随模型族变化的对照表。
"""
from __future__ import annotations
import logging
import httpx
from app.domain.generation import (
Failure,
GenerationRequest,
GenerationResult,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.base import GenerationProvider
from .capabilities import (
parse_size,
validate_output_format,
validate_quality,
validate_size,
)
from .client import OpenAiClient
from .models import ImagesResponse, ImageSubmission
from .tasks import SyncTaskRegistry
log = logging.getLogger(__name__)
#: `creation.generation_models.provider_code` 里对应本适配器的值。
PROVIDER_CODE = "openai-gpt-image"
#: 下载参考图的超时。这是一次内部/公网取图,与生成耗时无关,故很短。
_INPUT_FETCH_TIMEOUT_SEC = 15.0
#: 参考图大小上限。服务商限制单图 < 50MB;这里取更小的值,因为我们还要把它整个
#: 读进内存再转发——一张几十 MB 的图会让内存占用随并发线性上涨。
_MAX_INPUT_BYTES = 20 * 1024 * 1024
class OpenAiImageProvider(GenerationProvider):
"""gpt-image-2 的图片生成适配器。"""
def __init__(
self,
client: OpenAiClient,
registry: SyncTaskRegistry,
default_model: str = "",
input_fetch_client: httpx.Client | None = None,
):
self._client = client
self._registry = registry
self._default_model = default_model
self._owns_fetch_client = input_fetch_client is None
self._fetch_client = input_fetch_client or httpx.Client(
timeout=_INPUT_FETCH_TIMEOUT_SEC, follow_redirects=True
)
@property
def provider_code(self) -> str:
return PROVIDER_CODE
@property
def media_kind(self) -> MediaKind:
return MediaKind.IMAGE
# ------------------------------------------------------------- submit
def submit(self, request: GenerationRequest) -> str:
if request.media_kind is not MediaKind.IMAGE:
raise ValueError(
f"{PROVIDER_CODE} 只处理图片生成,收到 {request.media_kind.value}"
)
submission = self._to_submission(request)
# 参数校验放在提交进注册表**之前**:违规请求应当立刻以 400 回给调用方,
# 而不是变成一个注定失败的后台任务——后者要等一轮轮询才被发现,还占了一个
# 线程池槽位。
validate_size(submission.size)
validate_quality(submission.quality)
validate_output_format(
submission.output_format, transparent=submission.background == "transparent"
)
if request.input_url:
# 图生图:先取图。**取图也放在受理之前**——取不到就是参数问题
# (URL 不可达),应当立刻拒绝而不是变成一个后台失败。
image_bytes, filename, content_type = self._fetch_input(request.input_url)
work = lambda: self._run_edit(
submission, image_bytes, filename, content_type
)
else:
work = lambda: self._run_generate(submission)
# 注册表满时 `submit` 抛 RuntimeError,这里**刻意不接**:归成 ValueError 让上层
# 映射成 invalid_request 是错的——那不是请求的问题而是容量问题,且稍后重试会
# 好。原样上抛,由端点映射为 provider_unavailable(503)。
return self._registry.submit(work)
def _to_submission(self, request: GenerationRequest) -> ImageSubmission:
"""领域请求 → 厂商请求体。
模型优先取请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的默认值
——适配层不自行选模型,且任务行的快照必须与实际调用一致,否则无法对账。
"""
model = request.model or self._default_model
if not model:
raise ValueError(
"未指定模型:请求未携带 model 且 OPENAI_IMAGE_MODEL 未配置"
)
return ImageSubmission(
model=model,
prompt=request.prompt or "",
# 领域层的 ratio/resolution 是为视频设计的;图片用像素尺寸。宽高都有
# 才拼 size,否则交给模型自选(auto)——半个尺寸不如不给。
size=(
f"{request.width_px}x{request.height_px}"
if request.width_px and request.height_px
else None
),
safety_identifier=request.safety_identifier,
)
# -------------------------------------------------------------- fetch
def fetch(self, provider_request_id: str) -> GenerationResult:
result = self._registry.fetch(provider_request_id)
if result is None:
# 未知 ID:从未存在过,或进程重启后丢失(两者无法区分,见 tasks.py)。
# 抛 KeyError 让端点映射成 task_not_found——调用方要做的是停止轮询,
# 而不是退避重试。
raise KeyError(provider_request_id)
return result
# ------------------------------------------------- 实际的同步调用
def _run_generate(self, submission: ImageSubmission) -> GenerationResult:
"""文生图。在注册表的后台线程里执行。"""
payload = self._client.post_json(
"/images/generations", submission.to_json_body()
)
return self._to_result(ImagesResponse.from_payload(payload))
def _run_edit(
self,
submission: ImageSubmission,
image_bytes: bytes,
filename: str,
content_type: str,
) -> GenerationResult:
"""图生图(图像编辑)。走 multipart。"""
payload = self._client.post_multipart(
"/images/edits",
data=submission.to_form_fields(),
# 字段名用 `image[]`:编辑接口支持多图且共用该字段名。单图时也用它,
# 避免"单图一种写法、多图另一种"的分叉。
files=[("image[]", (filename, image_bytes, content_type))],
)
return self._to_result(ImagesResponse.from_payload(payload))
def _to_result(self, response: ImagesResponse) -> GenerationResult:
"""厂商响应 → 领域结果。"""
image = response.first_image()
if image is None or not image.b64_json:
# 2xx 却没有图片数据。当成可重试的上游异常,而不是把一个没有产物的
# 「成功」放给上层去空指针。
log.error("OpenAI 返回 2xx 但缺少图片数据:%r", response.raw_keys())
return GenerationResult(
provider_request_id="",
state=TaskState.FAILED,
failure=Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"MissingImageData",
"服务商返回成功但响应中没有图片数据",
),
)
size = parse_size(response.size or "")
return GenerationResult(
# 真实 ID 由注册表在 fetch 时填入——这里填不了,因为工作函数运行时
# 还拿不到自己的任务 ID。
provider_request_id="",
state=TaskState.SUCCEEDED,
progress=100,
# ⚠️ 产物是 base64 字节而非可访问 URL。这里放 data URI 只是为了让领域
# 模型的「SUCCEEDED 必须有 asset_url」不变式成立,**它不是一个可长期
# 保存的地址**:一张 1024×1024 的 png 约 1~2MB,base64 后更大,绝不
# 应该原样写进 media.assets.external_url。转存落地后这里应改为返回
# 上传后的 object key。
asset_url=f"data:image/{response.output_format or 'png'};base64,"
+ image.b64_json,
# 无时效——字节在手,不像火山 URL 会过期。用 None 而非一个假的秒数。
asset_url_expires_in_sec=None,
width_px=size.width if size else None,
height_px=size.height if size else None,
output_format=response.output_format,
billing_tokens=response.total_tokens,
)
# ------------------------------------------------------------ helpers
def _fetch_input(self, url: str) -> tuple[bytes, str, str]:
"""下载参考图,供 multipart 上传。
服务商要求 png / webp / jpg 且单图 < 50MB。这里额外设了更小的上限,因为
字节要整个进内存再转发。
"""
try:
with self._fetch_client.stream("GET", url) as response:
response.raise_for_status()
declared = response.headers.get("Content-Length")
if declared and int(declared) > _MAX_INPUT_BYTES:
raise ValueError(
f"参考图过大({declared} 字节),上限 {_MAX_INPUT_BYTES}"
)
chunks: list[bytes] = []
total = 0
for chunk in response.iter_bytes():
total += len(chunk)
if total > _MAX_INPUT_BYTES:
# 不信 Content-Length:它可能缺失或撒谎,边读边数才拦得住。
raise ValueError(f"参考图超过上限 {_MAX_INPUT_BYTES} 字节")
chunks.append(chunk)
content_type = response.headers.get("Content-Type", "image/png")
except httpx.HTTPError as exc:
raise ValueError(f"参考图无法下载:{url}({exc})") from exc
return b"".join(chunks), "input.png", content_type.split(";")[0]
def close(self) -> None:
if self._owns_fetch_client:
self._fetch_client.close()