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

406 lines
15 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 适配器的测试:文生图、图生图、响应映射。
全部离线(`httpx.MockTransport`),并用同步的注册表配置(`max_workers=1`)+ 轮询
等待,使断言不依赖时序。
"""
from __future__ import annotations
import base64
import time
import httpx
import pytest
from app.domain.generation import (
GenerationRequest,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.openai.client import OpenAiClient
from app.providers.openai.image import PROVIDER_CODE, OpenAiImageProvider
from app.providers.openai.tasks import SyncTaskRegistry
FAKE_API_KEY = "dummy-api-key-for-tests"
MODEL = "gpt-image-2"
PNG_B64 = base64.b64encode(b"fake-png-bytes").decode()
#: 官方文档「Generate image」一节的响应形状。
OFFICIAL_RESPONSE = {
"created": 1713833628,
"data": [{"b64_json": PNG_B64}],
"size": "1024x1024",
"output_format": "png",
"quality": "high",
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50,
"input_tokens_details": {"text_tokens": 10, "image_tokens": 40},
},
}
def provider_with(handler, *, fetch_handler=None, **kwargs) -> OpenAiImageProvider:
client = OpenAiClient(
api_key=FAKE_API_KEY,
base_url="https://api.openai.invalid/v1",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
fetch_client = (
httpx.Client(transport=httpx.MockTransport(fetch_handler))
if fetch_handler
else None
)
return OpenAiImageProvider(
client,
SyncTaskRegistry(max_workers=1, max_entries=8),
input_fetch_client=fetch_client,
**kwargs,
)
def image_request(**kwargs) -> GenerationRequest:
defaults = {
"job_id": "job-1",
"media_kind": MediaKind.IMAGE,
"model": MODEL,
"prompt": "一只柯基在草地上",
}
return GenerationRequest(**{**defaults, **kwargs})
def await_result(provider: OpenAiImageProvider, task_id: str, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = provider.fetch(task_id)
if result.state.is_terminal:
return result
time.sleep(0.01)
raise AssertionError("任务未在超时内结束")
class TestIdentity:
def test_provider_code_matches_the_catalog_column(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).provider_code
== PROVIDER_CODE
)
def test_declares_itself_as_an_image_provider(self):
assert (
provider_with(lambda r: httpx.Response(200, json={})).media_kind
is MediaKind.IMAGE
)
def test_rejects_a_misrouted_video_request(self):
with pytest.raises(ValueError, match="只处理图片生成"):
provider_with(lambda r: httpx.Response(200, json={})).submit(
image_request(media_kind=MediaKind.VIDEO)
)
class TestSubmitIsNonBlockingDespiteTheSyncVendor:
"""整个 tasks 层存在的理由:服务商同步,但我们对外必须是异步的。"""
def test_submit_returns_a_task_id_without_waiting_for_the_image(self):
import threading
release = threading.Event()
def handler(request):
release.wait(timeout=3)
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
begin = time.monotonic()
task_id = provider.submit(image_request())
elapsed = time.monotonic() - begin
assert task_id.startswith("local-")
assert elapsed < 0.5
assert provider.fetch(task_id).state is TaskState.RUNNING
release.set()
await_result(provider, task_id)
class TestTextToImage:
def test_posts_json_to_the_generations_endpoint(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen["path"] = request.url.path
seen["body"] = json.loads(request.content)
seen["auth"] = request.headers.get("Authorization")
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(provider, provider.submit(image_request()))
assert seen["path"].endswith("/images/generations")
assert seen["auth"] == f"Bearer {FAKE_API_KEY}"
assert seen["body"]["prompt"] == "一只柯基在草地上"
assert seen["body"]["model"] == MODEL
# 一个任务一个产物:generation_jobs.output_asset_id 是单值。
assert seen["body"]["n"] == 1
def test_pixel_size_is_sent_only_when_both_dimensions_are_given(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(
provider, provider.submit(image_request(width_px=1536, height_px=864))
)
assert seen["size"] == "1536x864"
def test_half_a_size_is_treated_as_no_size_at_all(self):
# 只给宽不给高时交给模型自选,而不是猜一个高度出来。
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(provider, provider.submit(image_request(width_px=1024)))
assert "size" not in seen
def test_safety_identifier_is_sent_as_the_vendors_user_field(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler)
await_result(
provider, provider.submit(image_request(safety_identifier="abc123hash"))
)
# 厂商字段名是 user,不是 safety_identifier。
assert seen["user"] == "abc123hash"
assert "safety_identifier" not in seen
class TestModelResolution:
def test_request_model_wins_over_the_configured_default(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
import json
seen.update(json.loads(request.content))
return httpx.Response(200, json=OFFICIAL_RESPONSE)
provider = provider_with(handler, default_model="fallback")
await_result(provider, provider.submit(image_request(model="snapshot-model")))
assert seen["model"] == "snapshot-model"
def test_no_model_anywhere_fails_before_any_call(self):
def handler(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="未指定模型"):
provider_with(handler).submit(image_request(model=""))
class TestLocalValidationHappensBeforeAccepting:
"""违规请求应当立刻被拒,而不是变成一个注定失败的后台任务。
后者要等一轮轮询才被发现,还白占一个线程池槽位。
"""
def test_bad_size_is_rejected_synchronously(self):
def handler(request):
raise AssertionError("不应发出请求")
with pytest.raises(ValueError, match="16 整除"):
provider_with(handler).submit(image_request(width_px=1000, height_px=864))
class TestImageToImage:
def test_downloads_the_input_then_posts_multipart_to_edits(self):
seen: dict = {}
def openai_handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["content_type"] = request.headers.get("Content-Type", "")
seen["raw"] = request.content
return httpx.Response(200, json=OFFICIAL_RESPONSE)
def fetch_handler(request: httpx.Request) -> httpx.Response:
seen["fetched"] = str(request.url)
return httpx.Response(
200,
content=b"reference-image-bytes",
headers={"Content-Type": "image/png"},
)
provider = provider_with(openai_handler, fetch_handler=fetch_handler)
await_result(
provider, provider.submit(image_request(input_url="https://x/ref.png"))
)
assert seen["fetched"] == "https://x/ref.png"
# 编辑接口走 multipart 而非 JSON——`image` 是文件。
assert seen["path"].endswith("/images/edits")
assert "multipart/form-data" in seen["content_type"]
assert b"reference-image-bytes" in seen["raw"]
# 字段名统一用 image[](该接口支持多图且共用此名),避免单图多图两种写法。
assert b'name="image[]"' in seen["raw"]
def test_an_undownloadable_input_is_rejected_synchronously(self):
"""取不到参考图就是参数问题,应立刻拒绝而不是变成后台失败。"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def fetch_handler(request):
raise httpx.ConnectError("refused")
with pytest.raises(ValueError, match="无法下载"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://unreachable/ref.png")
)
def test_an_oversized_input_is_rejected_by_its_declared_length(self):
"""第一道:声明的 Content-Length 就超了,连读都不必读。"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def fetch_handler(request):
return httpx.Response(200, content=b"x" * (21 * 1024 * 1024))
with pytest.raises(ValueError, match="参考图过大"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://x/huge.png")
)
def test_an_oversized_input_is_caught_even_when_the_length_lies(self):
"""第二道:不信 Content-Length——它可能缺失或撒谎,必须边读边数。
用分块流式响应(无 Content-Length)构造这种情形:只有累计计数才拦得住。
"""
def openai_handler(request):
raise AssertionError("不应发出生成请求")
def chunks():
# 每块 1MB,共 21MB,分块传输故无 Content-Length。
for _ in range(21):
yield b"x" * (1024 * 1024)
def fetch_handler(request):
return httpx.Response(200, content=chunks())
with pytest.raises(ValueError, match="超过上限"):
provider_with(openai_handler, fetch_handler=fetch_handler).submit(
image_request(input_url="https://x/lying.png")
)
class TestResponseMapping:
def test_dimensions_come_straight_from_the_response(self):
"""不像火山那边要查对照表——响应直接给 size。"""
provider = provider_with(lambda r: httpx.Response(200, json=OFFICIAL_RESPONSE))
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.SUCCEEDED
assert (result.width_px, result.height_px) == (1024, 1024)
assert result.output_format == "png"
assert result.billing_tokens == 100
assert result.failure is None
def test_arbitrary_size_is_parsed_too(self):
payload = {**OFFICIAL_RESPONSE, "size": "1536x864"}
provider = provider_with(lambda r: httpx.Response(200, json=payload))
result = await_result(provider, provider.submit(image_request()))
assert (result.width_px, result.height_px) == (1536, 864)
def test_asset_url_is_a_data_uri_with_no_expiry(self):
"""产物是 base64 字节,不是可访问 URL。
没有「24 小时过期」问题(与火山不同),故 expires_in 为 None 而不是编一个
假的秒数。但它**也不是可长期保存的地址**——绝不能原样写进 external_url。
"""
provider = provider_with(lambda r: httpx.Response(200, json=OFFICIAL_RESPONSE))
result = await_result(provider, provider.submit(image_request()))
assert result.asset_url.startswith("data:image/png;base64,")
assert PNG_B64 in result.asset_url
assert result.asset_url_expires_in_sec is None
def test_a_2xx_without_image_data_is_a_retryable_failure(self):
"""服务商说成功却没给图——那不是成功。"""
provider = provider_with(
lambda r: httpx.Response(200, json={"created": 1, "data": []})
)
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "MissingImageData"
class TestFailurePropagation:
def test_vendor_rate_limit_becomes_a_retryable_failure(self):
def handler(request):
return httpx.Response(
429,
json={"error": {"message": "slow down", "type": "rate_limit_exceeded"}},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.user_facing is UserFacing.RATE_LIMITED
def test_insufficient_quota_is_not_worth_retrying(self):
def handler(request):
return httpx.Response(
429,
json={"error": {"message": "no credit", "code": "insufficient_quota"}},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.failure.retry is Retry.QUOTA
assert result.failure.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_content_policy_violation_is_terminal(self):
def handler(request):
return httpx.Response(
400,
json={"error": {"message": "blocked", "code": "moderation_blocked"}},
)
provider = provider_with(handler)
result = await_result(provider, provider.submit(image_request()))
assert result.failure.retry is Retry.NEVER
assert result.failure.user_facing is UserFacing.CONTENT_REJECTED
class TestUnknownTaskId:
def test_fetch_raises_key_error_so_the_endpoint_can_map_it(self):
provider = provider_with(lambda r: httpx.Response(200, json={}))
with pytest.raises(KeyError):
provider.fetch("local-never-existed")