feat(inference): LLM 客户端全异步化(T8 架构审查整改)
- Issue9: client.py 由同步 httpx.Client 全异步化 - LLMClient Protocol / HttpLLMClient.chat → async;httpx.AsyncClient + asyncio.sleep 退避 - __enter__/__exit__ → __aenter__/__aexit__(async with 生命周期闭环) - engine.py chat/chat_structured/_call 全部 async + await - FakeLLMClient.chat → async;测试用 anyio pytest 插件转换(engine 32 + client 10 用例) - 同步 inference-engine spec 与 milestone3 review 的 httpx 描述 - 全量 182 passed / 100.00%(987 stmts/252 br)
This commit is contained in:
@@ -37,9 +37,13 @@ def test_client_implements_protocol():
|
||||
assert proto_params.issubset(impl_params)
|
||||
|
||||
|
||||
def test_chat_success():
|
||||
# ---------- T8: 全异步化(async 接口) ----------
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_success_async():
|
||||
"""chat 为 async 接口,返回文本与用量(T8)。"""
|
||||
client = make_client(_ok_handler)
|
||||
text, usage = client.chat(
|
||||
text, usage = await client.chat(
|
||||
model="deepseek-chat",
|
||||
messages=[ChatMessage(role="user", content="hi")],
|
||||
temperature=0.2,
|
||||
@@ -49,20 +53,16 @@ def test_chat_success():
|
||||
assert usage.input_tokens == 10 and usage.output_tokens == 5
|
||||
|
||||
|
||||
def test_chat_requires_api_key():
|
||||
with pytest.raises(LLMNotConfiguredError):
|
||||
HttpLLMClient(base_url="https://x", api_key="")
|
||||
|
||||
|
||||
def test_client_context_manager_closes_transport():
|
||||
# with 块退出后底层 httpx.Client 应被关闭(释放连接)
|
||||
with HttpLLMClient(
|
||||
@pytest.mark.anyio
|
||||
async def test_client_async_context_manager_closes_transport():
|
||||
"""async with 块退出后底层 httpx.AsyncClient 应被关闭(T8)。"""
|
||||
async with HttpLLMClient(
|
||||
base_url="https://api.test.local",
|
||||
api_key="sk-test",
|
||||
transport=httpx.MockTransport(_ok_handler),
|
||||
) as client:
|
||||
assert isinstance(client, HttpLLMClient)
|
||||
text, _ = client.chat(
|
||||
text, _ = await client.chat(
|
||||
model="deepseek-chat",
|
||||
messages=[ChatMessage(role="user", content="hi")],
|
||||
temperature=0.2,
|
||||
@@ -72,18 +72,25 @@ def test_client_context_manager_closes_transport():
|
||||
assert client._client.is_closed is True
|
||||
|
||||
|
||||
def test_chat_timeout():
|
||||
def test_chat_requires_api_key():
|
||||
with pytest.raises(LLMNotConfiguredError):
|
||||
HttpLLMClient(base_url="https://x", api_key="")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_timeout():
|
||||
def slow(request):
|
||||
raise httpx.ReadTimeout("slow")
|
||||
|
||||
with pytest.raises(LLMTimeoutError):
|
||||
make_client(slow, retry_backoff=(0, 0)).chat(
|
||||
await make_client(slow, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_5xx_retry_then_network_error():
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_5xx_retry_then_network_error():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
@@ -91,26 +98,28 @@ def test_chat_5xx_retry_then_network_error():
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
await make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 3 # 初始 + 2 次退避重试(间隔 0/0.01)
|
||||
|
||||
|
||||
def test_chat_network_error_retry_then_network_error():
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_network_error_retry_then_network_error():
|
||||
# 非超时的网络错误走 httpx.HTTPError 分支,重试耗尽后抛 LLMNetworkError
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
await make_client(handler, retry_backoff=(0, 0)).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_4xx_no_retry():
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_4xx_no_retry():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
@@ -118,14 +127,15 @@ def test_chat_4xx_no_retry():
|
||||
return httpx.Response(429, text="rate limit")
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler).chat(
|
||||
await make_client(handler).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 1 # 4xx 不重试
|
||||
|
||||
|
||||
def test_chat_3xx_no_retry():
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_3xx_no_retry():
|
||||
# 3xx(重定向,httpx 不自动跟随)不得被当作成功,应立即抛 LLMNetworkError
|
||||
calls = {"n": 0}
|
||||
|
||||
@@ -134,14 +144,15 @@ def test_chat_3xx_no_retry():
|
||||
return httpx.Response(302, headers={"Location": "https://elsewhere"})
|
||||
|
||||
with pytest.raises(LLMNetworkError):
|
||||
make_client(handler).chat(
|
||||
await make_client(handler).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
assert calls["n"] == 1 # 3xx 不重试
|
||||
|
||||
|
||||
def test_chat_malformed_response_raises_llm_response_error():
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_malformed_response_raises_llm_response_error():
|
||||
# 2xx 但响应体结构损坏(非 JSON / 缺 choices/message/content)→ LLMResponseError
|
||||
def not_json(request):
|
||||
return httpx.Response(200, text="not-json")
|
||||
@@ -151,7 +162,7 @@ def test_chat_malformed_response_raises_llm_response_error():
|
||||
|
||||
for handler in (not_json, missing_key):
|
||||
with pytest.raises(LLMResponseError):
|
||||
make_client(handler).chat(
|
||||
await make_client(handler).chat(
|
||||
model="m", messages=[ChatMessage(role="user", content="x")],
|
||||
temperature=0.2, max_tokens=100,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user