541376e9be
CI / ai-gates (push) Successful in 55s
拿到 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。四道门禁逐条读退出码,全绿。
434 lines
16 KiB
Python
434 lines
16 KiB
Python
"""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")
|