"""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")