Coverage for merco/core/llm/_client.py: 57%

284 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 14:04 +0800

1"""LLM 客户端 - OpenAI 兼容接口""" 

2import asyncio 

3import json 

4import logging 

5import re 

6import tempfile 

7import time 

8from abc import ABC, abstractmethod 

9from pathlib import Path 

10from typing import Any, Optional, AsyncIterator 

11 

12logger = logging.getLogger("merco.llm") 

13 

14 

15# 清理字符串中的代理对字符(surrogates),防止 API 序列化崩溃 

16_SURROGATE_RE = re.compile(r'[\ud800-\udfff]') 

17 

18 

19def _clean_surrogates(obj): 

20 """递归清理数据结构中的代理对字符""" 

21 if isinstance(obj, str): 

22 return _SURROGATE_RE.sub('', obj) 

23 if isinstance(obj, list): 

24 return [_clean_surrogates(x) for x in obj] 

25 if isinstance(obj, dict): 

26 return {k: _clean_surrogates(v) for k, v in obj.items()} 

27 return obj 

28 

29 

30# ── Thinking 提取策略体系 ───────────────────────────────────────── 

31 

32# 统一的 think 标签对配置(开标签 → 闭标签) 

33# 新增 provider 只需在此添加一对标签 

34THINK_TAG_PAIRS: tuple[tuple[str, str], ...] = ( 

35 ("<think>", "[/think]"), # MiniMax 等 

36 ("<think>", "</think>"), # 标准格式 

37 ("<thinking>", "</thinking>"), # XML 格式 

38) 

39 

40 

41def _build_think_block_re() -> re.Pattern: 

42 """根据 THINK_TAG_PAIRS 构建匹配所有 think 块的正则。 

43 

44 构造策略: 

45 1. 收集所有模式(块 + 单标签) 

46 2. 按"长度降序"排序——块(长)必须先于裸标签(短) 

47 3. 用非捕获组组装,避免 sub() 因空捕获组错位 

48 

49 排序是关键:Python re 交替是"左优先",短模式排在前面会"吞掉"块首字符, 

50 导致长块模式失配。例如块模式 <think>...[/think] 在前时,遇到 <think>hi</think> 

51 会先匹配 <think> 裸标签,块模式再也匹配不上。 

52 """ 

53 raw_patterns = [] 

54 for open_tag, close_tag in THINK_TAG_PAIRS: 

55 eo = re.escape(open_tag) 

56 ec = re.escape(close_tag) 

57 raw_patterns.append(f"{eo}.*?{ec}") # 完整块 

58 raw_patterns.append(eo) # 孤儿开标签 

59 raw_patterns.append(ec) # 孤儿闭标签 

60 # 长度降序:块(最长)必须先于单标签 

61 raw_patterns.sort(key=len, reverse=True) 

62 return re.compile("|".join(f"(?:{p})" for p in raw_patterns), re.IGNORECASE | re.DOTALL) 

63 

64 

65_THINK_BLOCK_RE = _build_think_block_re() 

66 

67 

68def _strip_think_tags(text: str) -> str: 

69 """chunk 安全:只去 think 标签,不动空白。 

70 流式场景专用——每 chunk 调一次,.strip() 会破坏词边界。""" 

71 return _THINK_BLOCK_RE.sub("", text) 

72 

73 

74def _clean_content(text: str) -> str: 

75 """完整 content 终态处理:去 think 标签 + 去前后空白。 

76 非流式专用——完整响应只需调一次,strip 收尾合理。""" 

77 return _THINK_BLOCK_RE.sub("", text).strip() 

78 

79 

80def _extract_usage(response) -> dict: 

81 """从 API 响应中提取 token 用量,包括缓存命中""" 

82 usage = response.usage 

83 if not usage: 

84 return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} 

85 

86 result = { 

87 "prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0, 

88 "completion_tokens": getattr(usage, "completion_tokens", 0) or 0, 

89 "total_tokens": getattr(usage, "total_tokens", 0) or 0, 

90 } 

91 

92 # 缓存命中 — 多 provider 兼容采集 

93 # Anthropic: usage.cache_read_input_tokens / cache_creation_input_tokens 

94 cached = getattr(usage, "cache_read_input_tokens", None) 

95 if cached is not None: 

96 result["cache_read_tokens"] = cached 

97 result["cache_write_tokens"] = getattr(usage, "cache_creation_input_tokens", 0) or 0 

98 

99 # OpenAI: usage.prompt_tokens_details.cached_tokens 

100 details = getattr(usage, "prompt_tokens_details", None) 

101 if details is not None: 

102 cd = getattr(details, "cached_tokens", None) 

103 if cd is not None: 

104 result["cached_tokens"] = cd 

105 

106 return result 

107 

108 

109class ThinkingStrategy(ABC): 

110 """思考内容提取策略基类。子类注册到 ThinkingExtractor 后按优先级调用。""" 

111 

112 @abstractmethod 

113 def extract_from_delta(self, delta: Any) -> dict | None: 

114 """从流式 delta 中提取。返回 {'content'?: str, 'reasoning'?: str} 或 None。""" 

115 ... 

116 

117 @abstractmethod 

118 def extract_from_message(self, message: Any) -> dict | None: 

119 """从完整 message(非流式)中提取。""" 

120 ... 

121 

122 def reset(self) -> None: 

123 """重置跨 chunk 状态。""" 

124 pass 

125 

126 

127class DirectFieldStrategy(ThinkingStrategy): 

128 """直接从对象属性检查 reasoning_content / reasoning(scnet 等代理放在顶层字段)。""" 

129 

130 def extract_from_delta(self, delta: Any) -> dict | None: 

131 return self._check(delta) 

132 

133 def extract_from_message(self, message: Any) -> dict | None: 

134 return self._check(message) 

135 

136 @staticmethod 

137 def _check(obj: Any) -> dict | None: 

138 try: 

139 for attr in ("reasoning_content", "reasoning"): 

140 val = getattr(obj, attr, None) 

141 if val and isinstance(val, str): 

142 return {"reasoning": val} 

143 except Exception: 

144 pass 

145 return None 

146 

147 

148class ModelExtraStrategy(ThinkingStrategy): 

149 """从 model_extra 提取 reasoning_content / reasoning(OpenAI o1 / DeepSeek R1 等)。""" 

150 

151 def extract_from_delta(self, delta: Any) -> dict | None: 

152 return self._extract_from(delta) 

153 

154 def extract_from_message(self, message: Any) -> dict | None: 

155 return self._extract_from(message) 

156 

157 @staticmethod 

158 def _extract_from(obj: Any) -> dict | None: 

159 try: 

160 extra = getattr(obj, "model_extra", None) 

161 if isinstance(extra, dict): 

162 rc = extra.get("reasoning_content") or extra.get("reasoning") or "" 

163 if rc: 

164 return {"reasoning": str(rc)} 

165 except Exception: 

166 pass 

167 return None 

168 

169 

170class ThinkTagStrategy(ThinkingStrategy): 

171 """从 think 标签中提取思考内容。 

172 

173 使用统一的 THINK_TAG_PAIRS 配置,流式场景用状态机处理标签跨 chunk 的情况。 

174 新增标签格式只需修改 THINK_TAG_PAIRS。 

175 """ 

176 

177 def __init__(self): 

178 self._in_thinking = False 

179 self._open_tag = "" 

180 self._close_tag = "" 

181 

182 def reset(self) -> None: 

183 self._in_thinking = False 

184 self._open_tag = "" 

185 self._close_tag = "" 

186 

187 def extract_from_delta(self, delta: Any) -> dict | None: 

188 content = getattr(delta, "content", None) or "" 

189 if not content: 

190 return None 

191 

192 if self._in_thinking: 

193 # 继续处理跨 chunk 的 think 块 

194 if self._close_tag in content: 

195 before_close, after_close = content.split(self._close_tag, 1) 

196 self._in_thinking = False 

197 result: dict = {} 

198 if before_close: 

199 result["reasoning"] = before_close 

200 result["content"] = after_close 

201 return result 

202 else: 

203 return {"reasoning": content, "content": ""} 

204 else: 

205 # 检测开标签(使用 THINK_TAG_PAIRS,优先匹配较长的) 

206 for ot, ct in THINK_TAG_PAIRS: 

207 if ot in content: 

208 before_open, rest = content.split(ot, 1) 

209 if ct in rest: 

210 thinking, after_close = rest.split(ct, 1) 

211 result: dict = {} 

212 if thinking: 

213 result["reasoning"] = thinking 

214 result["content"] = before_open + after_close 

215 return result 

216 else: 

217 # think 块跨 chunk,进入状态机 

218 self._in_thinking = True 

219 self._open_tag = ot 

220 self._close_tag = ct 

221 result: dict = {"reasoning": rest, "content": before_open} 

222 return result 

223 return {"content": content} 

224 

225 def extract_from_message(self, message: Any) -> dict | None: 

226 """非流式:完整 content 一次性提取所有 think 块。""" 

227 content = getattr(message, "content", None) or "" 

228 for open_tag, close_tag in THINK_TAG_PAIRS: 

229 if open_tag in content: 

230 pattern = re.compile( 

231 re.escape(open_tag) + r"(.*?)" + re.escape(close_tag), 

232 re.DOTALL 

233 ) 

234 thinking_parts = pattern.findall(content) 

235 if thinking_parts: 

236 cleaned = pattern.sub("", content).strip() 

237 thinking = "\n\n".join(t.strip() for t in thinking_parts) 

238 result: dict = {"reasoning": thinking} 

239 if cleaned: 

240 result["content"] = cleaned 

241 return result 

242 return None 

243 

244 

245class ThinkingExtractor: 

246 """策略链式思考提取器。按注册顺序尝试各策略, 

247 首个返回非空 reasoning 的结果生效。""" 

248 

249 def __init__(self): 

250 self._strategies: list[ThinkingStrategy] = [ 

251 DirectFieldStrategy(), 

252 ModelExtraStrategy(), 

253 ThinkTagStrategy(), 

254 ] 

255 

256 def register(self, strategy: ThinkingStrategy) -> None: 

257 """扩展点:注册新策略,插到链首。""" 

258 self._strategies.insert(0, strategy) 

259 

260 def extract_from_delta(self, delta: Any) -> dict: 

261 """流式块提取,返回 dict 可能含 content / reasoning。""" 

262 for s in self._strategies: 

263 result = s.extract_from_delta(delta) 

264 if result is not None: 

265 if "content" not in result: 

266 raw = getattr(delta, "content", None) or "" 

267 result["content"] = _strip_think_tags(raw) 

268 return result 

269 raw = getattr(delta, "content", None) or "" 

270 return {"content": _strip_think_tags(raw)} 

271 

272 def extract_from_message(self, message: Any) -> dict: 

273 """非流式响应提取。""" 

274 for s in self._strategies: 

275 result = s.extract_from_message(message) 

276 if result is not None: 

277 return result 

278 return {} 

279 

280 def reset(self) -> None: 

281 for s in self._strategies: 

282 s.reset() 

283 

284 

285class LLMClient: 

286 """OpenAI 兼容的 LLM 客户端 — 纯传输层,重试/恢复由 Agent RecoveryPipeline 统一处理""" 

287 

288 def __init__( 

289 self, 

290 api_key: str, 

291 model: str = "gpt-4", 

292 base_url: Optional[str] = None, 

293 temperature: float = 0.7, 

294 max_tokens: int = 4096, 

295 cooldown: float = 0, # 请求最小间隔(秒),0=不禁用 

296 extra_params: Optional[dict] = None, # 额外 API 参数(top_p / seed 等) 

297 headers: Optional[dict] = None, # 自定义 HTTP header(X-Title 等) 

298 stream_options: Optional[dict] = None, # 流式额外参数(如 {"include_usage": True}) 

299 ): 

300 self.model = model 

301 self.temperature = temperature 

302 self.max_tokens = max_tokens 

303 self.cooldown = cooldown 

304 self.extra_params = extra_params or {} 

305 self.stream_options = stream_options 

306 self._last_request_time = 0.0 

307 self._client_ready = False 

308 

309 import httpx 

310 from openai import AsyncOpenAI 

311 

312 client_kwargs: dict = { 

313 "api_key": api_key, 

314 "max_retries": 0, 

315 "timeout": httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), 

316 } 

317 if base_url: 

318 client_kwargs["base_url"] = base_url 

319 if headers: 

320 client_kwargs["http_client"] = httpx.AsyncClient(headers=headers) 

321 

322 self.client = AsyncOpenAI(**client_kwargs) 

323 

324 # ── 公共方法 ───────────────────────────────────────────────── 

325 

326 async def chat( 

327 self, 

328 messages: list[dict], 

329 tools: list[dict] = None, 

330 tool_choice: str | dict[str, Any] = "auto", 

331 ) -> dict: 

332 """非流式聊天""" 

333 params = self._build_params(messages, tools, tool_choice) 

334 response = await self._request(params) 

335 result = self._parse_response(response) 

336 logger.debug("← API 响应: finish=%s content_len=%d tool_calls=%d", 

337 result.get("finish_reason"), len(result.get("content", "")), 

338 len(result.get("tool_calls", []))) 

339 return result 

340 

341 async def chat_stream( 

342 self, 

343 messages: list[dict], 

344 tools: list[dict] = None, 

345 tool_choice: str | dict[str, Any] = "auto", 

346 ) -> AsyncIterator[dict]: 

347 """流式聊天""" 

348 extractor = ThinkingExtractor() 

349 params = self._build_params(messages, tools, tool_choice, stream=True) 

350 stream = await self._request(params) 

351 assert stream is not None 

352 async for chunk in stream: 

353 if parsed := self._parse_chunk(chunk, extractor): 

354 yield parsed 

355 

356 # ── 私有方法 ───────────────────────────────────────────────── 

357 

358 def _build_params( 

359 self, messages: list[dict], tools: list[dict] | None, 

360 tool_choice: str | dict[str, Any], stream: bool = False, 

361 ) -> dict: 

362 """构造 API 请求参数""" 

363 params = { 

364 "model": self.model, 

365 "messages": _clean_surrogates(messages), 

366 "temperature": self.temperature, 

367 "max_tokens": self.max_tokens, 

368 } 

369 if stream: 

370 params["stream"] = True 

371 options = {"include_usage": True} 

372 if self.stream_options: 

373 options.update(self.stream_options) 

374 params["stream_options"] = options 

375 if tools: 

376 params["tools"] = tools 

377 params["tool_choice"] = tool_choice 

378 params.update(self.extra_params) 

379 return params 

380 

381 async def _ensure_client_ready(self): 

382 """确保 httpx 异步连接池初始化完成(首次请求前执行一次)。""" 

383 if self._client_ready: 

384 return 

385 await asyncio.sleep(0) 

386 self._client_ready = True 

387 

388 async def _request(self, params: dict): 

389 """发送请求(含 cooldown),不重试。错误原样上抛交 Agent RecoveryPipeline。""" 

390 logger.debug("→ API 请求: model=%s messages=%d tools=%d", 

391 self.model, len(params.get("messages", [])), 

392 len(params.get("tools", []))) 

393 

394 if self.cooldown > 0: 

395 elapsed = time.monotonic() - self._last_request_time 

396 if elapsed < self.cooldown: 

397 wait = self.cooldown - elapsed 

398 logger.debug("⏳ 冷却 %.1fs(距上次请求 %.1fs)", wait, elapsed) 

399 await asyncio.sleep(wait) 

400 

401 try: 

402 await self._ensure_client_ready() 

403 response = await self.client.chat.completions.create(**params) 

404 except asyncio.CancelledError: 

405 logger.debug("⚠ 请求被取消") 

406 raise 

407 self._last_request_time = time.monotonic() 

408 return response 

409 

410 @staticmethod 

411 def _normalize_tool_calls(tool_calls: list) -> list[dict]: 

412 """统一将 SDK tool_call 列表归一为安全 dict 列表,避免 None 穿透。""" 

413 result = [] 

414 for tc in tool_calls: 

415 entry: dict = { 

416 "id": tc.id or "", 

417 "name": tc.function.name or "", 

418 "arguments": tc.function.arguments or "", 

419 } 

420 idx = getattr(tc, "index", None) 

421 if idx is not None: 

422 entry["index"] = idx 

423 result.append(entry) 

424 return result 

425 

426 def _parse_response(self, response) -> dict: 

427 """解析完整响应""" 

428 if not response.choices: 

429 return {"content": "", "finish_reason": None, "usage": _extract_usage(response)} 

430 

431 choice = response.choices[0] 

432 message = choice.message 

433 

434 content = message.content or "" 

435 usage_data = _extract_usage(response) 

436 result = { 

437 "role": message.role, 

438 "content": content, 

439 "reasoning": "", 

440 "finish_reason": choice.finish_reason, 

441 "usage": usage_data, 

442 } 

443 

444 extracted = ThinkingExtractor().extract_from_message(message) 

445 if extracted: 

446 if extracted.get("reasoning"): 

447 result["reasoning"] = extracted["reasoning"] 

448 if "content" in extracted: 

449 result["content"] = extracted["content"] if extracted["content"] else "" 

450 

451 # 兜底:无论哪个策略命中,都清理 content 中残留的 <thinking> 标签 

452 # 防止 DirectFieldStrategy 命中后 ThinkTagStrategy 没跑导致标签泄漏 

453 result["content"] = _clean_content(result["content"]) 

454 

455 if message.tool_calls: 

456 normalized = self._normalize_tool_calls(message.tool_calls) 

457 logger.debug("_parse_response: 检测到 %d 个 tool_calls: %s", 

458 len(normalized), 

459 [f"{tc['name']}({tc['id'][:8]})" for tc in normalized]) 

460 result["tool_calls"] = [ 

461 {**tc, "arguments": json.loads(tc["arguments"]) if tc["arguments"] else {}} 

462 for tc in normalized 

463 ] 

464 

465 return result 

466 

467 def _parse_chunk(self, chunk, extractor: Optional["ThinkingExtractor"] = None) -> Optional[dict]: 

468 """解析流式块""" 

469 choice = chunk.choices[0] if chunk.choices else None 

470 if not choice: 

471 return None 

472 

473 delta = choice.delta 

474 if delta is None: 

475 return None 

476 result = {} 

477 ex = extractor if extractor is not None else ThinkingExtractor() 

478 extracted = ex.extract_from_delta(delta) 

479 if extracted.get("content"): 

480 result["content"] = _strip_think_tags(extracted["content"]) 

481 if extracted.get("reasoning"): 

482 result["reasoning"] = _strip_think_tags(extracted["reasoning"]) 

483 if delta.tool_calls: 

484 result["tool_calls"] = self._normalize_tool_calls(delta.tool_calls) 

485 tcs = result["tool_calls"] 

486 logger.debug("_parse_chunk: 流式收到 %d 个 tool_calls delta: index=%s name=%s", 

487 len(tcs), 

488 [tc.get("index", "?") for tc in tcs], 

489 [tc.get("name", "") or "?" for tc in tcs]) 

490 

491 if choice.finish_reason: 

492 result["finish_reason"] = choice.finish_reason 

493 if hasattr(chunk, "usage") and chunk.usage: 

494 result["usage"] = { 

495 "prompt_tokens": chunk.usage.prompt_tokens, 

496 "completion_tokens": chunk.usage.completion_tokens, 

497 "total_tokens": chunk.usage.total_tokens, 

498 } 

499 return result if result else None