import pytest import httpx from genesis.inference.client import HttpLLMClient, LLMClient from genesis.inference.exceptions import ( LLMNetworkError, LLMNotConfiguredError, LLMResponseError, LLMTimeoutError, ) from genesis.inference.types import ChatMessage def make_client(handler, *, api_key="sk-test", retry_backoff=(0.0, 0.0)): return HttpLLMClient( base_url="https://api.test.local", api_key=api_key, timeout_sec=0.1, retry_backoff=retry_backoff, transport=httpx.MockTransport(handler), ) def _ok_handler(request): return httpx.Response(200, json={ "choices": [{"message": {"content": "Hello"}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}, }) def test_client_implements_protocol(): # 结构性断言:HttpLLMClient.chat 的关键字参数签名与 LLMClient Protocol 一致 import inspect proto_params = set(inspect.signature(LLMClient.chat).parameters) impl_params = set(inspect.signature(HttpLLMClient.chat).parameters) assert proto_params.issubset(impl_params) def test_chat_success(): client = make_client(_ok_handler) text, usage = client.chat( model="deepseek-chat", messages=[ChatMessage(role="user", content="hi")], temperature=0.2, max_tokens=100, ) assert text == "Hello" 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_chat_timeout(): def slow(request): raise httpx.ReadTimeout("slow") with pytest.raises(LLMTimeoutError): 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(): calls = {"n": 0} def handler(request): calls["n"] += 1 return httpx.Response(500, text="boom") with pytest.raises(LLMNetworkError): 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(): # 非超时的网络错误走 httpx.HTTPError 分支,重试耗尽后抛 LLMNetworkError def handler(request): raise httpx.ConnectError("connection refused") with pytest.raises(LLMNetworkError): 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(): calls = {"n": 0} def handler(request): calls["n"] += 1 return httpx.Response(429, text="rate limit") with pytest.raises(LLMNetworkError): 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(): # 3xx(重定向,httpx 不自动跟随)不得被当作成功,应立即抛 LLMNetworkError calls = {"n": 0} def handler(request): calls["n"] += 1 return httpx.Response(302, headers={"Location": "https://elsewhere"}) with pytest.raises(LLMNetworkError): 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(): # 2xx 但响应体结构损坏(非 JSON / 缺 choices/message/content)→ LLMResponseError def not_json(request): return httpx.Response(200, text="not-json") def missing_key(request): return httpx.Response(200, json={"choices": []}) for handler in (not_json, missing_key): with pytest.raises(LLMResponseError): make_client(handler).chat( model="m", messages=[ChatMessage(role="user", content="x")], temperature=0.2, max_tokens=100, )