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
+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