新增 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:
@@ -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
|
||||
Reference in New Issue
Block a user