8275a04903
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 目录。
151 lines
4.9 KiB
Python
151 lines
4.9 KiB
Python
"""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)
|