feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
CI / ai-gates (push) Successful in 38s

新增 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:
2026-09-21 12:10:07 +08:00
parent 062e11b1ff
commit 8275a04903
21 changed files with 2375 additions and 11 deletions
View File
+150
View File
@@ -0,0 +1,150 @@
"""gpt-image-2 尺寸与参数约束的测试。
每个用例对应官方文档里的一条约束。这些约束若搞错,表现是用户提交后拿到一个失败
任务(服务商 400),而本地测试全绿——所以值得逐条钉住。
"""
from __future__ import annotations
import pytest
from app.providers.openai.capabilities import (
parse_size,
validate_output_format,
validate_quality,
validate_size,
)
class TestStandardSizesAndAutoAlwaysPass:
"""文档明示支持的取值不做算术校验。"""
def test_standard_sizes(self):
for size in ("1024x1024", "1536x1024", "1024x1536"):
validate_size(size)
def test_auto(self):
validate_size("auto")
def test_none(self):
validate_size(None)
class TestEdgeMultipleOf16:
"""官方:宽高都必须能被 16 整除。"""
def test_both_divisible_passes(self):
validate_size("1536x864") # 文档给的例子
def test_width_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1000x864")
def test_height_not_divisible_is_rejected(self):
with pytest.raises(ValueError, match="16 整除"):
validate_size("1536x865")
class TestAspectRatioBounds:
"""官方:宽高比必须在 1:3 ~ 3:1 之间。"""
def test_three_to_one_is_allowed(self):
# 2880x960 = 3:1,且都能被 16 整除。
validate_size("2880x960")
def test_one_to_three_is_allowed(self):
validate_size("960x2880")
def test_portrait_at_the_pixel_limit_is_allowed(self):
"""竖版 4k(2160x3840)与横版同像素量,必须放行。
按「逐边」理解上限会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的
标准竖图尺寸——故逐边理解必然过严。见 capabilities 里的说明。
"""
validate_size("2160x3840")
def test_wider_than_three_to_one_is_rejected(self):
# 3840x960 = 4:1。
with pytest.raises(ValueError, match="宽高比"):
validate_size("3840x960")
def test_taller_than_one_to_three_is_rejected(self):
# 640x2880 = 1:4.5,且在像素上限之内,故触发的必须是宽高比那条。
with pytest.raises(ValueError, match="宽高比"):
validate_size("640x2880")
class TestMaximumResolution:
def test_the_maximum_itself_is_allowed(self):
validate_size("3840x2160")
def test_longest_edge_beyond_the_cap_is_rejected(self):
with pytest.raises(ValueError, match="最长边"):
validate_size("4096x2048")
def test_within_edge_cap_but_beyond_the_pixel_cap_is_rejected(self):
# 3840x2688:最长边合规、宽高比合规,但总像素超了。
with pytest.raises(ValueError, match="总像素"):
validate_size("3840x2688")
def test_a_tall_portrait_within_both_caps_passes(self):
validate_size("1024x2176")
class TestMalformedSize:
def test_not_a_size_string(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("large")
def test_missing_separator(self):
with pytest.raises(ValueError, match="形态非法"):
validate_size("1024")
def test_parse_returns_none_for_garbage(self):
assert parse_size("nonsense") is None
def test_parse_reads_a_valid_pair(self):
size = parse_size("1536x864")
assert (size.width, size.height) == (1536, 864)
assert str(size) == "1536x864"
class TestQuality:
def test_supported_values_pass(self):
for quality in ("low", "medium", "high", "auto"):
validate_quality(quality)
def test_xhigh_is_rejected_because_it_is_2_5_only(self):
# 官方:xhigh 与 max 仅 gpt-image-2.5 系列支持。
with pytest.raises(ValueError, match="xhigh"):
validate_quality("xhigh")
def test_max_is_rejected_too(self):
with pytest.raises(ValueError):
validate_quality("max")
def test_none_passes(self):
validate_quality(None)
class TestOutputFormat:
def test_supported_formats(self):
for fmt in ("png", "jpeg", "webp"):
validate_output_format(fmt)
def test_unknown_format_is_rejected(self):
with pytest.raises(ValueError, match="非法"):
validate_output_format("gif")
def test_jpeg_with_transparent_background_is_rejected(self):
"""jpeg 没有 alpha 通道。
服务商不会因此报错——只会给一张背景被填成不透明的图,而那是**静默的**
不符合预期,比报错难查得多。
"""
with pytest.raises(ValueError, match="alpha"):
validate_output_format("jpeg", transparent=True)
def test_png_with_transparent_background_is_fine(self):
validate_output_format("png", transparent=True)
validate_output_format("webp", transparent=True)
+202
View File
@@ -0,0 +1,202 @@
"""OpenAI HTTP 传输层的测试:鉴权、两种载荷形式、错误解包。
全部离线(`httpx.MockTransport`),不触网。
"""
from __future__ import annotations
import httpx
import pytest
from app.domain.generation import Retry
from app.providers.openai.client import (
DEFAULT_BASE_URL,
OpenAiClient,
OpenAiError,
)
FAKE_API_KEY = "dummy-api-key-for-tests"
def client_with(handler, **kwargs) -> OpenAiClient:
return OpenAiClient(
api_key=FAKE_API_KEY,
client=httpx.Client(transport=httpx.MockTransport(handler)),
**kwargs,
)
class TestCredentialDiscipline:
def test_an_empty_key_fails_loudly_at_construction(self):
"""缺密钥是配置问题,应当在装配时就说清楚,而不是等第一次调用。"""
with pytest.raises(ValueError, match="OPENAI_API_KEY 未配置"):
OpenAiClient(api_key="")
def test_the_key_is_sent_as_a_bearer_token(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("Authorization")
return httpx.Response(200, json={"ok": True})
client_with(handler).post_json("/images/generations", {})
assert seen["auth"] == f"Bearer {FAKE_API_KEY}"
def test_organization_header_is_sent_only_when_configured(self):
seen: list = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.get("OpenAI-Organization"))
return httpx.Response(200, json={})
client_with(handler).post_json("/x", {})
client_with(handler, organization="org-abc").post_json("/x", {})
assert seen == [None, "org-abc"]
class TestUrlComposition:
def test_default_base_url_is_the_official_one(self):
assert DEFAULT_BASE_URL == "https://api.openai.com/v1"
def test_a_trailing_slash_on_the_base_url_does_not_double_up(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
return httpx.Response(200, json={})
client_with(handler, base_url="https://proxy.invalid/v1/").post_json("/img", {})
assert seen["url"] == "https://proxy.invalid/v1/img"
class TestPayloadForms:
def test_post_json_sends_json(self):
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["content_type"] = request.headers.get("Content-Type")
seen["body"] = request.content
return httpx.Response(200, json={"echo": 1})
result = client_with(handler).post_json("/x", {"prompt": "hi"})
assert seen["content_type"] == "application/json"
assert b'"prompt"' in seen["body"]
assert result == {"echo": 1}
def test_post_multipart_repeats_a_field_name(self):
"""用 list 而非 dict 传 files 的全部理由:同名字段可以重复。
编辑端点支持多图,它们共用字段名 `image[]`——dict 表达不了。
"""
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["content_type"] = request.headers.get("Content-Type", "")
seen["raw"] = request.content
return httpx.Response(200, json={})
client_with(handler).post_multipart(
"/images/edits",
data={"prompt": "merge"},
files=[
("image[]", ("a.png", b"first", "image/png")),
("image[]", ("b.png", b"second", "image/png")),
],
)
assert "multipart/form-data" in seen["content_type"]
assert seen["raw"].count(b'name="image[]"') == 2
assert b"first" in seen["raw"]
assert b"second" in seen["raw"]
def test_content_type_is_not_hand_written(self):
"""multipart 还需要 boundary,手写必错——交给 httpx 按载荷推断。"""
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["ct"] = request.headers.get("Content-Type", "")
return httpx.Response(200, json={})
client_with(handler).post_multipart(
"/x", data={}, files=[("image[]", ("a", b"x", "image/png"))]
)
assert "boundary=" in seen["ct"]
class TestErrorUnwrapping:
def test_the_vendor_error_envelope_is_parsed(self):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"message": "Rate limit reached",
"type": "rate_limit_exceeded",
"code": None,
"param": None,
}
},
)
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
error = caught.value
assert error.http_status == 429
assert error.failure.retry is Retry.BACKOFF
assert "Rate limit reached" in error.failure.message
def test_a_non_json_error_body_still_classifies_by_status(self):
"""错误体形状未经文档证实,解析不出时不能静默丢掉线索。"""
def handler(request):
return httpx.Response(502, text="<html>bad gateway</html>")
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
assert caught.value.failure.retry is Retry.BACKOFF
assert caught.value.failure.provider_code == "HTTP_502"
# 原始文本被带上,否则排查时只知道「502」。
assert "bad gateway" in caught.value.failure.message
def test_a_2xx_with_a_non_json_body_is_an_error_not_a_success(self):
def handler(request):
return httpx.Response(200, text="not json at all")
with pytest.raises(OpenAiError, match="不是合法 JSON"):
client_with(handler).post_json("/x", {})
def test_a_network_failure_is_classified_as_retryable(self):
"""⚠️ 同步接口的超时最危险:那次调用可能已经完成并计费。
图片接口没有任务 ID 可供事后查询,故这个窗口只能由 max_attempts 兜住。
"""
def handler(request):
raise httpx.ConnectTimeout("timed out")
with pytest.raises(OpenAiError) as caught:
client_with(handler).post_json("/x", {})
assert caught.value.http_status is None
assert caught.value.failure.retry is Retry.BACKOFF
assert caught.value.failure.provider_code == "NETWORK_ERROR"
class TestConnectionOwnership:
def test_an_injected_client_is_not_closed_by_us(self):
"""注入进来的连接池归调用方管理。
关掉它会让共享同一个 client 的其他适配器一起失效。
"""
injected = httpx.Client(
transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))
)
OpenAiClient(api_key=FAKE_API_KEY, client=injected).close()
assert not injected.is_closed
def test_our_own_client_is_closed_on_exit(self):
with OpenAiClient(api_key=FAKE_API_KEY) as client:
inner = client._client
assert inner.is_closed
+119
View File
@@ -0,0 +1,119 @@
"""OpenAI 错误分类的逐条测试。
`errors.py` 顶部标了「映射未经官方文档证实」——正因为它是推断出来的,才更要把
每条分支钉住:日后拿到真实错误数据来校正时,改动的影响面一眼可见。
"""
from __future__ import annotations
import pytest
from app.domain.generation import Retry, UserFacing
from app.providers.openai.errors import classify
class TestHighConfidenceOverrides:
"""文档没给错误码表,故只对少数语义明确的取值加细。"""
@pytest.mark.parametrize("field", ["code", "type"])
def test_insufficient_quota_is_not_worth_retrying(self, field):
failure = classify(429, **{f"error_{field}": "insufficient_quota"})
assert failure.retry is Retry.QUOTA
assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED
@pytest.mark.parametrize("field", ["code", "type"])
def test_rate_limit_backs_off(self, field):
failure = classify(429, **{f"error_{field}": "rate_limit_exceeded"})
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
@pytest.mark.parametrize("code", ["moderation_blocked", "content_policy_violation"])
def test_content_rejection_is_terminal(self, code):
# 同样的输入必然同样被拒——重试只是白花一次配额。
failure = classify(400, error_code=code)
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.CONTENT_REJECTED
def test_invalid_key_is_our_problem_not_the_users(self):
failure = classify(401, error_code="invalid_api_key")
assert failure.retry is Retry.NEVER
# 用户看到的是「服务端错误」,不是「你的内容有问题」。
assert failure.user_facing is UserFacing.SERVER_ERROR
def test_code_wins_over_type_when_both_are_present(self):
# code 比 type 更具体,故优先。
failure = classify(
429, error_type="rate_limit_exceeded", error_code="insufficient_quota"
)
assert failure.retry is Retry.QUOTA
class TestStatusCodeFallback:
"""拿不到 type/code 时只靠状态码——这是本模块的主依据。"""
def test_429_without_any_hint_is_treated_as_rate_limiting(self):
"""429 两义兼有;猜成配额耗尽会把一次可重试的限流判死,代价更大。"""
failure = classify(429)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
def test_400_without_any_hint_does_not_blame_the_prompt(self):
"""400 同时用于参数错与内容被拒。
错报「内容被拒」会让用户去改一个其实没问题的提示词,故归服务端错误。
"""
failure = classify(400)
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.SERVER_ERROR
@pytest.mark.parametrize("status", [401, 403])
def test_auth_failures_are_terminal(self, status):
assert classify(status).retry is Retry.NEVER
@pytest.mark.parametrize("status", [404, 409, 422])
def test_other_4xx_are_terminal(self, status):
assert classify(status).retry is Retry.NEVER
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_5xx_is_retryable(self, status):
failure = classify(status)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_network_failure_is_retryable(self):
# http_status=None 表示连都没连上。
failure = classify(None, message="connect timeout")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_an_unknown_status_leans_towards_retrying(self):
"""未知一律偏可重试:把可恢复的抖动判死是更糟的错误。
重试预算本身有上限(max_attempts),所以偏向重试是有界的。
"""
assert classify(599).retry is Retry.BACKOFF
class TestProviderCodeIsNeverEmpty:
"""它是排查时的第一线索,空字符串等于把线索丢了。"""
def test_code_is_preferred(self):
assert classify(400, error_type="t", error_code="c").provider_code == "c"
def test_type_is_used_when_code_is_absent(self):
assert classify(400, error_type="t").provider_code == "t"
def test_status_label_is_the_last_resort(self):
assert classify(503).provider_code == "HTTP_503"
def test_network_failures_get_a_named_label_too(self):
assert classify(None).provider_code == "NETWORK_ERROR"
class TestMessageIsCarriedThrough:
def test_message_is_kept(self):
assert classify(400, message="bad size").message == "bad size"
def test_missing_message_becomes_empty_not_none(self):
# 契约里 message 是字符串;None 会在序列化处变成 null 而非 ""。
assert classify(400).message == ""
+405
View File
@@ -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")
+249
View File
@@ -0,0 +1,249 @@
"""同步→异步任务注册表的测试。
这一层是 gpt-image-2 与 Seedance 的根本差异所在(同步接口没有任务 ID),也是唯一
让本服务**持有状态**的地方。它写错的表现是丢任务或内存泄漏,两者都要到生产才显形,
所以逐条钉住。
"""
from __future__ import annotations
import threading
import time
import pytest
from app.domain.generation import (
Failure,
GenerationResult,
Retry,
TaskState,
UserFacing,
)
from app.providers.openai.client import OpenAiError
from app.providers.openai.tasks import LOCAL_TASK_PREFIX, SyncTaskRegistry
def succeeded(url: str = "data:image/png;base64,AAA") -> GenerationResult:
return GenerationResult(
provider_request_id="", state=TaskState.SUCCEEDED, progress=100, asset_url=url
)
def wait_until_done(registry: SyncTaskRegistry, task_id: str, timeout: float = 3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = registry.fetch(task_id)
if result is not None and result.state.is_terminal:
return result
time.sleep(0.01)
raise AssertionError(f"任务 {task_id} 在 {timeout}s 内未结束")
class TestSubmitReturnsImmediately:
def test_submit_does_not_block_on_the_slow_call(self):
"""这是整层存在的理由:同步调用必须在后台跑,提交立刻返回。"""
registry = SyncTaskRegistry(max_workers=2)
started = threading.Event()
release = threading.Event()
def slow():
started.set()
release.wait(timeout=3)
return succeeded()
begin = time.monotonic()
task_id = registry.submit(slow)
elapsed = time.monotonic() - begin
assert started.wait(timeout=2)
# 提交本身几乎不耗时,尽管那个调用还在跑。
assert elapsed < 0.5
assert registry.fetch(task_id).state is TaskState.RUNNING
release.set()
wait_until_done(registry, task_id)
registry.shutdown()
def test_task_id_is_prefixed_so_logs_distinguish_it_from_a_vendor_id(self):
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
assert task_id.startswith(LOCAL_TASK_PREFIX)
registry.shutdown()
def test_ids_are_unique(self):
registry = SyncTaskRegistry()
ids = {registry.submit(succeeded) for _ in range(20)}
assert len(ids) == 20
registry.shutdown()
class TestInFlightAndCompletedStates:
def test_running_reports_zero_progress(self):
# 同步接口不提供进度,只能报 0。契约本就不承诺平滑进度(ADR-023 §10)。
registry = SyncTaskRegistry()
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
result = registry.fetch(task_id)
assert result.state is TaskState.RUNNING
assert result.progress == 0
release.set()
registry.shutdown()
def test_completed_result_is_returned_with_the_real_task_id(self):
"""工作函数拿不到自己的任务 ID,故由注册表在 fetch 时填入。"""
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.SUCCEEDED
assert result.provider_request_id == task_id
assert result.asset_url is not None
registry.shutdown()
def test_result_can_be_fetched_more_than_once(self):
# Java 侧可能重复轮询同一个已完成任务(比如一次响应丢了)。
registry = SyncTaskRegistry()
task_id = registry.submit(succeeded)
wait_until_done(registry, task_id)
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
registry.shutdown()
class TestUnknownIdIsIndistinguishableOnPurpose:
def test_never_existed_returns_none(self):
"""从未存在过与「进程重启后丢失」无法区分——不假装区分。
两种情形对调用方的处置相同:停止轮询、任务判失败。
"""
assert SyncTaskRegistry().fetch("local-nonexistent") is None
class TestFailurePropagation:
def test_a_classified_provider_error_keeps_its_classification(self):
registry = SyncTaskRegistry()
failure = Failure(
Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, "insufficient_quota", "no credit"
)
def failing():
raise OpenAiError(failure, 429)
task_id = registry.submit(failing)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.QUOTA
assert result.failure.provider_code == "insufficient_quota"
registry.shutdown()
def test_an_unclassified_exception_becomes_a_retryable_failure_naming_its_type(
self,
):
"""未分类异常不该让任务卡住,也不该丢掉线索。
把异常类型名放进 provider_code——否则排查时只有一句空洞的错误消息。
"""
registry = SyncTaskRegistry()
def boom():
raise ZeroDivisionError("unexpected")
task_id = registry.submit(boom)
result = wait_until_done(registry, task_id)
assert result.state is TaskState.FAILED
assert result.failure.retry is Retry.BACKOFF
assert result.failure.provider_code == "ZeroDivisionError"
registry.shutdown()
class TestCapacityIsBounded:
def test_submitting_beyond_the_cap_is_rejected_rather_than_evicting(self):
"""满了要拒绝新提交,**不能挤掉在途任务**。
挤掉一个在途任务会让一个可能正在计费的调用变成无人认领的开销。
"""
registry = SyncTaskRegistry(max_workers=1, max_entries=2)
release = threading.Event()
def blocked():
release.wait(timeout=3)
return succeeded()
registry.submit(blocked)
registry.submit(blocked)
with pytest.raises(RuntimeError, match="上限"):
registry.submit(blocked)
release.set()
registry.shutdown()
def test_concurrency_is_capped_by_max_workers(self):
"""并发上限同时也是「同时有多少钱在花」的上界。"""
registry = SyncTaskRegistry(max_workers=2, max_entries=10)
concurrent = []
lock = threading.Lock()
release = threading.Event()
def work():
with lock:
concurrent.append(1)
peak = len(concurrent)
release.wait(timeout=3)
with lock:
concurrent.pop()
return succeeded(f"data:image/png;base64,{peak}")
for _ in range(5):
registry.submit(work)
time.sleep(0.2)
with lock:
assert len(concurrent) <= 2
release.set()
registry.shutdown()
class TestExpiryOnlyTouchesCompletedEntries:
def test_completed_entries_expire(self):
registry = SyncTaskRegistry(completed_ttl_sec=0.05, max_entries=10)
task_id = registry.submit(succeeded)
wait_until_done(registry, task_id)
time.sleep(0.1)
# 清理发生在下一次 submit 时(不额外起一个定时线程——本服务的规模用不上)。
registry.submit(succeeded)
assert registry.fetch(task_id) is None
registry.shutdown()
def test_in_flight_entries_are_never_expired(self):
"""在途任务无论多久都不清——它可能正在花钱。"""
registry = SyncTaskRegistry(completed_ttl_sec=0.01, max_entries=10)
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
time.sleep(0.1)
registry.submit(succeeded)
# 仍在注册表里。
assert registry.fetch(task_id) is not None
assert registry.fetch(task_id).state is TaskState.RUNNING
release.set()
registry.shutdown()
def test_ttl_starts_at_completion_not_at_creation(self):
"""否则一个耗时接近 TTL 的任务会在刚完成时就被清掉——结果还没被取走。"""
registry = SyncTaskRegistry(completed_ttl_sec=5.0, max_entries=10)
release = threading.Event()
task_id = registry.submit(lambda: (release.wait(timeout=3), succeeded())[1])
time.sleep(0.15)
release.set()
wait_until_done(registry, task_id)
registry.submit(succeeded)
assert registry.fetch(task_id).state is TaskState.SUCCEEDED
registry.shutdown()