Files
lixi 8275a04903
CI / ai-gates (push) Successful in 38s
feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
新增 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 目录。
2026-09-21 12:10:07 +08:00

152 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
)