Files
patbond-ai/tests/providers/openai/test_capabilities.py
T
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

151 lines
4.9 KiB
Python
Raw 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 尺寸与参数约束的测试。
每个用例对应官方文档里的一条约束。这些约束若搞错,表现是用户提交后拿到一个失败
任务(服务商 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)