Coverage for src\genesis\inference\client.py: 100%
58 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1from __future__ import annotations
3import asyncio
4import json
5from typing import Protocol, Sequence
7import httpx
9from .exceptions import (
10 LLMNetworkError,
11 LLMNotConfiguredError,
12 LLMResponseError,
13 LLMTimeoutError,
14)
15from .types import ChatMessage, TokenUsage
18class LLMClient(Protocol):
19 """LLM 调用适配器(可注入替换为 Fake)。T8 起为 async 接口。"""
21 async def chat(
22 self,
23 *,
24 model: str,
25 messages: list[ChatMessage],
26 temperature: float,
27 max_tokens: int,
28 ) -> tuple[str, TokenUsage]: ...
31class HttpLLMClient:
32 """OpenAI Chat Completions 兼容的 httpx 异步实现;支持重试(指数退避)。
34 T8(架构审查整改):由同步 httpx.Client 全异步化——async def chat、
35 httpx.AsyncClient、asyncio.sleep 退避、__aenter__/__aexit__ 生命周期闭环。
36 """
38 def __init__(
39 self,
40 *,
41 base_url: str,
42 api_key: str,
43 timeout_sec: float = 60.0,
44 retry_backoff: Sequence[float] = (1.0, 3.0, 7.0),
45 transport: httpx.BaseTransport | None = None,
46 ) -> None:
47 if not api_key:
48 raise LLMNotConfiguredError("LLM API key 未配置(DEEPSEEK_API_KEY / LLM_BASE_URL)")
49 self._base_url = base_url.rstrip("/")
50 self._api_key = api_key
51 self._timeout_sec = timeout_sec
52 self._retry_backoff = retry_backoff
53 self._transport = transport
54 self._client = httpx.AsyncClient(timeout=timeout_sec, transport=transport)
56 async def __aenter__(self) -> HttpLLMClient:
57 """支持 async with 块:退出时自动关闭底层连接。"""
58 return self
60 async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
61 await self._client.aclose()
63 async def chat(
64 self,
65 *,
66 model: str,
67 messages: list[ChatMessage],
68 temperature: float,
69 max_tokens: int,
70 ) -> tuple[str, TokenUsage]:
71 url = f"{self._base_url}/v1/chat/completions"
72 payload = {
73 "model": model,
74 "messages": [{"role": m.role, "content": m.content} for m in messages],
75 "temperature": temperature,
76 "max_tokens": max_tokens,
77 }
78 headers = {
79 "Authorization": f"Bearer {self._api_key}",
80 "Content-Type": "application/json",
81 }
83 # 每次调用新建 httpx.AsyncClient,绑定到当前事件循环。
84 # 兼容「同步门禁中多次 asyncio.run 驱动 async chat_structured」场景,
85 # 避免复用 __init__ 中创建的 client 因首个循环关闭而报 Event loop is closed。
86 async with httpx.AsyncClient(timeout=self._timeout_sec, transport=self._transport) as client:
87 attempts = 1 + len(self._retry_backoff)
88 last_error: Exception | None = None
89 for attempt in range(attempts):
90 if attempt > 0:
91 await asyncio.sleep(self._retry_backoff[attempt - 1])
92 try:
93 resp = await client.post(url, json=payload, headers=headers)
94 except httpx.TimeoutException as exc:
95 last_error = exc
96 continue
97 except httpx.HTTPError as exc:
98 last_error = exc
99 continue
101 if resp.status_code >= 500:
102 last_error = LLMNetworkError(f"LLM 5xx: {resp.status_code}")
103 continue
104 if resp.status_code >= 400:
105 raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
106 if not (200 <= resp.status_code < 300):
107 # 3xx 重定向不自动跟随,不得误判为成功
108 raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
110 try:
111 data = resp.json()
112 content = data["choices"][0]["message"]["content"]
113 except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
114 # 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试
115 raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc
116 usage_raw = data.get("usage", {})
117 usage = TokenUsage(
118 input_tokens=usage_raw.get("prompt_tokens", 0),
119 output_tokens=usage_raw.get("completion_tokens", 0),
120 )
121 return content, usage
123 if isinstance(last_error, httpx.TimeoutException):
124 raise LLMTimeoutError(f"LLM 超时({self._timeout_sec}s)") from last_error
125 raise LLMNetworkError(f"LLM 调用失败(重试耗尽): {last_error}") from last_error