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
+150
View File
@@ -0,0 +1,150 @@
"""gpt-image-2 尺寸与参数约束的测试。
每个用例对应官方文档里的一条约束。这些约束若搞错,表现是用户提交后拿到一个失败
任务(服务商 400),而本地测试全绿——所以值得逐条钉住。
"""
from __future__ import annotations
import pytest
from app.providers.openai.capabilities import (
parse_size,
validate_output_format,
validate_quality,
validate_size,
)
class TestStandardSizesAndAutoAlwaysPass:
"""文档明示支持的取值不做算术校验。"""
def test_standard_sizes(self):
for size in ("1024x1024", "1536x1024", "1024x1536"):
validate_size(size)
def test_auto(self):
validate_size("auto")
def test_none(self):
validate_size(None)
class TestEdgeMultipleOf16:
"""官方:宽高都必须能被 16 整除。"""
def test_both_divisible_passes(self):
validate_size("1536x864") # 文档给的例子
def test_width_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1000x864")
def test_height_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1536x865")
class TestAspectRatioBounds:
"""官方:宽高比必须在 1:3 ~ 3:1 之间。"""
def test_three_to_one_is_allowed(self):
# 2880x960 = 3:1,且都能被 16 整除。
validate_size("2880x960")
def test_one_to_three_is_allowed(self):
validate_size("960x2880")
def test_portrait_at_the_pixel_limit_is_allowed(self):
"""竖版 4k(2160x3840)与横版同像素量,必须放行。
按「逐边」理解上限会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的
标准竖图尺寸——故逐边理解必然过严。见 capabilities 里的说明。
"""
validate_size("2160x3840")
def test_wider_than_three_to_one_is_rejected(self):
# 3840x960 = 4:1。
with pytest.raises(ValueError, match="宽高比"):
validate_size("3840x960")
def test_taller_than_one_to_three_is_rejected(self):
# 640x2880 = 1:4.5,且在像素上限之内,故触发的必须是宽高比那条。
with pytest.raises(ValueError, match="宽高比"):
validate_size("640x2880")
class TestMaximumResolution:
def test_the_maximum_itself_is_allowed(self):
validate_size("3840x2160")
def test_longest_edge_beyond_the_cap_is_rejected(self):
with pytest.raises(ValueError, match="最长边"):
validate_size("4096x2048")
def test_within_edge_cap_but_beyond_the_pixel_cap_is_rejected(self):
# 3840x2688:最长边合规、宽高比合规,但总像素超了。
with pytest.raises(ValueError, match="总像素"):
validate_size("3840x2688")
def test_a_tall_portrait_within_both_caps_passes(self):
validate_size("1024x2176")
class TestMalformedSize:
def test_not_a_size_string(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("large")
def test_missing_separator(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("1024")
def test_parse_returns_none_for_garbage(self):
assert parse_size("nonsense") is None
def test_parse_reads_a_valid_pair(self):
size = parse_size("1536x864")
assert (size.width, size.height) == (1536, 864)
assert str(size) == "1536x864"
class TestQuality:
def test_supported_values_pass(self):
for quality in ("low", "medium", "high", "auto"):
validate_quality(quality)
def test_xhigh_is_rejected_because_it_is_2_5_only(self):
# 官方:xhigh 与 max 仅 gpt-image-2.5 系列支持。
with pytest.raises(ValueError, match="xhigh"):
validate_quality("xhigh")
def test_max_is_rejected_too(self):
with pytest.raises(ValueError):
validate_quality("max")
def test_none_passes(self):
validate_quality(None)
class TestOutputFormat:
def test_supported_formats(self):
for fmt in ("png", "jpeg", "webp"):
validate_output_format(fmt)
def test_unknown_format_is_rejected(self):
with pytest.raises(ValueError, match="非法"):
validate_output_format("gif")
def test_jpeg_with_transparent_background_is_rejected(self):
"""jpeg 没有 alpha 通道。
服务商不会因此报错——只会给一张背景被填成不透明的图,而那是**静默的**
不符合预期,比报错难查得多。
"""
with pytest.raises(ValueError, match="alpha"):
validate_output_format("jpeg", transparent=True)
def test_png_with_transparent_background_is_fine(self):
validate_output_format("png", transparent=True)
validate_output_format("webp", transparent=True)