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
+151
View File
@@ -0,0 +1,151 @@
"""gpt-image-2 的尺寸与参数约束。
与火山那边不同,OpenAI 的图片接口不用「分辨率 + 宽高比」两个字段,而是一个
`WIDTHxHEIGHT` 字符串,并对它施加几条**算术约束**。逐条取自官方文档:
- 宽高**都必须能被 16 整除**
- 宽高比必须在 `1:3` 到 `3:1` 之间
- 最大支持 `3840x2160`
- 超过 `2560x1440` 属**实验性**
- 另有「模型当前的像素与边长限制」——文档未给具体数值,故无法在本地校验
在本地先校验这些,是因为违反约束的请求会被 OpenAI 以 400 拒绝,而那要花一次
往返、计入配额,并表现为一个失败的任务。错误信息还能直接指出违反了哪一条。
**标准尺寸**(`1024x1024` / `1536x1024` / `1024x1536`)与 `auto` 一律放行,
不做算术校验:它们是文档明示支持的取值,其中 `auto` 由模型自行决定。
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
log = logging.getLogger(__name__)
#: 文档明示的标准尺寸,直接放行。
STANDARD_SIZES = frozenset({"1024x1024", "1536x1024", "1024x1536"})
#: 交给模型自选尺寸。
AUTO_SIZE = "auto"
#: `WIDTHxHEIGHT`。
_SIZE_PATTERN = re.compile(r"^(\d+)x(\d+)$")
#: 宽高必须同时是它的倍数。
EDGE_MULTIPLE = 16
#: 宽高比的允许区间(宽/高),即 1:3 ~ 3:1。
MIN_ASPECT = 1 / 3
MAX_ASPECT = 3.0
#: 最大支持尺寸,文档原文是「the maximum supported resolution is 3840x2160」。
#:
#: ⚠️ **这里有一处文档未明确、由我们推断的语义**:那句话没说清限制是「逐边」
#: (宽≤3840 且 高≤2160)还是「按总像素」。按逐边理解会**禁掉所有高于 2160 的
#: 竖图**,而 `1024x1536` 是文档明示的标准竖图尺寸、竖版显然受支持——故逐边理解
#: 必然过严。
#:
#: 采用的读法:**最长边 ≤ 3840,且总像素 ≤ 3840×2160**。它允许 `2160x3840`
#: 这类竖图(与横图同像素量),又拦住明显越界的尺寸。若日后拿到更明确的文档或
#: 实测结果,回来校正本处并更新取证页。
MAX_EDGE = 3840
MAX_PIXELS = 3840 * 2160
#: 超过此面积属实验性——放行但记一条日志,便于把偶发的异常表现与它关联起来。
#: 同样按面积而非逐边理解,理由同上。
EXPERIMENTAL_ABOVE_PIXELS = 2560 * 1440
#: 可选的画质档。xhigh 与 max 仅 2.5 系列支持,gpt-image-2 不含。
QUALITIES_GPT_IMAGE_2 = frozenset({"low", "medium", "high", "auto"})
#: 输出格式。注意 transparent 背景要求 png 或 webp。
OUTPUT_FORMATS = frozenset({"png", "jpeg", "webp"})
@dataclass(frozen=True)
class Size:
width: int
height: int
def __str__(self) -> str:
return f"{self.width}x{self.height}"
def parse_size(value: str) -> Size | None:
"""把 `WIDTHxHEIGHT` 解析成尺寸;标准尺寸与 `auto` 之外的非法串返回 None。"""
match = _SIZE_PATTERN.match(value.strip())
if not match:
return None
return Size(int(match.group(1)), int(match.group(2)))
def validate_size(value: str | None) -> None:
"""校验尺寸,不合法则抛 `ValueError`(消息指明违反了哪一条约束)。
`None`、`auto`、标准尺寸一律放行。
"""
if not value or value == AUTO_SIZE or value in STANDARD_SIZES:
return
size = parse_size(value)
if size is None:
raise ValueError(
f"size 形态非法:{value!r},应为 'WIDTHxHEIGHT'、'auto' "
f"或标准尺寸 {sorted(STANDARD_SIZES)}"
)
if size.width % EDGE_MULTIPLE or size.height % EDGE_MULTIPLE:
raise ValueError(f"size {size} 的宽高必须都能被 {EDGE_MULTIPLE} 整除")
# 宽高比先判:它是文档最明确的一条,且比尺寸上限更常被违反(比例写错比
# 尺寸写大更容易发生),先报它的错误信息更有用。
aspect = size.width / size.height
if not MIN_ASPECT <= aspect <= MAX_ASPECT:
raise ValueError(f"size {size} 的宽高比 {aspect:.2f} 超出允许区间 1:3 ~ 3:1")
if max(size.width, size.height) > MAX_EDGE:
raise ValueError(f"size {size} 的最长边超出上限 {MAX_EDGE}")
pixels = size.width * size.height
if pixels > MAX_PIXELS:
raise ValueError(
f"size {size} 的总像素 {pixels} 超出上限 {MAX_PIXELS}"
f"(即 3840x2160 的像素量)"
)
if pixels > EXPERIMENTAL_ABOVE_PIXELS:
# 放行但记一条:文档称实验性,出问题时这条日志能把现象与它关联起来。
log.info("size %s 超过 2560x1440,属服务商标注的实验性范围", size)
def validate_quality(value: str | None) -> None:
"""校验画质档。
只覆盖 gpt-image-2:`xhigh`/`max` 是 2.5 系列才支持的档,传给 gpt-image-2
会被拒。不按模型名分表是因为目前只接入一个图片模型——接入第二个时再分。
"""
if value and value not in QUALITIES_GPT_IMAGE_2:
raise ValueError(
f"quality {value!r} 不被 gpt-image-2 支持,"
f"可选:{sorted(QUALITIES_GPT_IMAGE_2)}"
f"(xhigh / max 仅 gpt-image-2.5 系列支持)"
)
def validate_output_format(value: str | None, *, transparent: bool = False) -> None:
"""校验输出格式。
:param transparent: 背景为透明时**只能** png 或 webp——jpeg 没有 alpha 通道,
指定它会得到一张背景被填成不透明的图,而那不会报错、只会静默不符合预期。
"""
if value and value not in OUTPUT_FORMATS:
raise ValueError(
f"output_format {value!r} 非法,可选:{sorted(OUTPUT_FORMATS)}"
)
if transparent and value == "jpeg":
raise ValueError(
"背景为 transparent 时 output_format 不能是 jpeg(无 alpha 通道),"
"请用 png 或 webp"
)