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

1from __future__ import annotations 

2 

3import asyncio 

4import json 

5from typing import Protocol, Sequence 

6 

7import httpx 

8 

9from .exceptions import ( 

10 LLMNetworkError, 

11 LLMNotConfiguredError, 

12 LLMResponseError, 

13 LLMTimeoutError, 

14) 

15from .types import ChatMessage, TokenUsage 

16 

17 

18class LLMClient(Protocol): 

19 """LLM 调用适配器(可注入替换为 Fake)。T8 起为 async 接口。""" 

20 

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]: ... 

29 

30 

31class HttpLLMClient: 

32 """OpenAI Chat Completions 兼容的 httpx 异步实现;支持重试(指数退避)。 

33 

34 T8(架构审查整改):由同步 httpx.Client 全异步化——async def chat、 

35 httpx.AsyncClient、asyncio.sleep 退避、__aenter__/__aexit__ 生命周期闭环。 

36 """ 

37 

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) 

55 

56 async def __aenter__(self) -> HttpLLMClient: 

57 """支持 async with 块:退出时自动关闭底层连接。""" 

58 return self 

59 

60 async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 

61 await self._client.aclose() 

62 

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 } 

82 

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 

100 

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]}") 

109 

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 

122 

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