新增 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:
@@ -0,0 +1,405 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user