Files
patbond-ai/tests/providers/openai/test_image.py
T
lixi 541376e9be
CI / ai-gates (push) Successful in 55s
fix(ai): 按官方 Error codes 页重写 OpenAI 错误映射
拿到 doc_temp/gpt错误码.md 后核对,**此前猜的六个键里错了五个**:
rate_limit_exceeded / moderation_blocked / content_policy_violation /
invalid_api_key / image_generation_user_error 在官方文档里都不存在。真实的是
type rate_limit_error + code slow_down、type service_unavailable_error + code
server_is_overloaded、四个 billing 码。唯一对的 insufficient_quota 也是宽 type
而非 code。这段历史留在 errors.py 顶部,作为凭记忆列错误码表代价的记录。

官方表的 13 条全是基础设施与计费,**没有任何内容审核/侵权码**——但图片接口确有
moderation 参数,说明审核存在、只是错误形态未文档化。直接后果:一次侵权或违规
拒绝很可能以普通 400 到达,只带一句人类可读消息。若一律归「服务端错误」,用户会
看到「服务出错了」并反复重试一个注定被拒的提示词。

故用 error.param 的有无做结构化区分(带 param = 参数错,不带 = 按内容被拒),
三条依据:文档的 service_tier 那条明确说参数错会带 param;内容拒绝不指向任何
具体参数;我方已在调用前校验过尺寸/画质/格式,能被 400 拒掉的参数嫌疑本就不大。
这一条是推断,走到时记 WARNING——那是日后校正它的唯一素材。client.py 相应补上
param 的解析。

两处顺带收获:
- 无 code 的 429 按限流处理,现在是**文档支持的默认**(六种 429 里只有「Rate
  limit reached for requests」没有文档化的 code),不再是猜测。
- 文档提醒视频过载已从 429 改判为 503。我们两者都归可重试,故不受影响;加了一条
  测试钉住这个「无影响」。

同一张表按 code 与 type 两个字段分别查:文档把标识列在哪个字段下,不保证响应里
它就在那个字段,查错的代价是把配额耗尽降级成限流、白烧一轮重试预算。

未接 Retry-After 并记录理由:Java 侧退避基线 30 秒、上限 10 分钟,已长于服务商
通常给的值,忽略只会偏慢、不会加剧限流;接它是一次跨语言契约变更。

测试 271 → 284。四道门禁逐条读退出码,全绿。
2026-09-21 15:52:25 +08:00

434 lines
16 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:
"""只验「厂商错误能原样穿过后台线程到达查询结果」,分类本身在 test_errors.py。
码名全部抄自官方 Error codes 页(`doc_temp/gpt错误码.md`)。
"""
def test_vendor_rate_limit_becomes_a_retryable_failure(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Your request rate increased too quickly",
"type": "rate_limit_error",
"code": "slow_down",
}
},
)
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_an_exhausted_credit_balance_is_not_worth_retrying(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Your organization has no prepaid credits remaining",
"type": "insufficient_quota",
"code": "credit_balance_exhausted",
}
},
)
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_a_bare_400_is_surfaced_as_a_content_refusal(self):
"""官方错误码表里**没有任何内容审核/侵权码**。
故一次「这个提示词不能生成」只能以通用 400 到达。不带 `error.param` 时按
内容被拒处理——详见 `errors.py` 的 `_classify_bad_request`。
"""
def handler(request):
return httpx.Response(
400,
json={
"error": {
"message": "Your request was rejected",
"type": "invalid_request_error",
}
},
)
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")