specmodule 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
llm/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """LLM 模块"""
2
+
3
+ from .config import LLMConfig
4
+ from .client import (
5
+ LLMError,
6
+ AnthropicClient,
7
+ OpenAIClient,
8
+ Message,
9
+ LLMResponse,
10
+ create_llm_client,
11
+ )
12
+
13
+ __all__ = [
14
+ "LLMConfig",
15
+ "LLMError",
16
+ "AnthropicClient",
17
+ "OpenAIClient",
18
+ "Message",
19
+ "LLMResponse",
20
+ "create_llm_client",
21
+ ]
llm/client.py ADDED
@@ -0,0 +1,654 @@
1
+ """LLM 客户端 —— 统一的 LLM 调用接口
2
+
3
+ 支持 Anthropic 和 OpenAI(兼容) 两种后端。
4
+
5
+ 面向 ModuleHarness harness body 的入口是 :meth:`complete`:接收渲染后的 prompt 与按调用
6
+ 覆盖的参数,流式 token 经 ``on_token`` 回调回传,结构化输出经 ``response_format`` / 强制
7
+ tool-use 原生适配,扩展思考(think)经各 provider 原生参数启用。
8
+
9
+ 错误契约
10
+ --------
11
+ 客户端只区分「调用成功 / 调用失败」。调用失败(鉴权失败、模型不存在、超时、网络错误、客户端
12
+ 未就绪等基础设施故障)抛 :class:`LLMError`,由 harness body 捕获后映射为
13
+ ``Failure(type="infrastructure")``,使 Runner 进入 ``ABORTED`` 停机——这类故障不可由重试同
14
+ 一次调用解决,停机交由 agent 决策(回滚/换模型/终止)。
15
+
16
+ 输出格式不合格**不在此处判断**——那是 body 的 outputformat 审查层职责(→
17
+ ``Failure(type="llm")``,运行续跑)。故客户端成功返回的 ``LLMResponse.content`` 始终是模型
18
+ 原始输出,校验留给上层。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Callable
28
+
29
+ from .config import LLMConfig
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # 错误类型
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ class LLMError(RuntimeError):
40
+ """LLM 调用的基础设施故障。
41
+
42
+ 涵盖鉴权失败(403)、模型不存在(404)、超时、网络错误、SDK 未安装/初始化失败等。
43
+ harness body 捕获后映射为 ``Failure(type="infrastructure")`` → Runner ``ABORTED``。
44
+ """
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 公开类型
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ @dataclass
53
+ class Message:
54
+ """统一消息格式(chat 多轮接口用;complete 单轮接口不直接使用)。"""
55
+ role: str # "system" | "user" | "assistant" | "tool"
56
+ content: str = ""
57
+ tool_calls: list[dict[str, Any]] | None = None
58
+ tool_call_id: str | None = None
59
+
60
+
61
+ @dataclass
62
+ class LLMResponse:
63
+ """LLM 响应。complete 成功时返回;调用失败抛 LLMError 而非返回此对象。"""
64
+ content: str = ""
65
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
66
+ usage: dict[str, int] = field(default_factory=dict)
67
+ finish_reason: str | None = None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # 共享辅助
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ # 已知的 OpenAI SDK 参数(直接入 kwargs,不归入 extra_body)
76
+ _KNOWN_OPENAI_PARAMS = frozenset({
77
+ "model", "messages", "temperature", "top_p", "n", "stream",
78
+ "stop", "max_tokens", "max_completion_tokens",
79
+ "presence_penalty", "frequency_penalty", "logit_bias", "user",
80
+ "response_format", "seed", "tools", "tool_choice",
81
+ "reasoning_effort", "logprobs", "top_logprobs",
82
+ "functions", "function_call",
83
+ "stream_options", "extra_headers",
84
+ })
85
+
86
+ # 已知的 Anthropic SDK 参数
87
+ _KNOWN_ANTHROPIC_PARAMS = frozenset({
88
+ "model", "messages", "system", "max_tokens", "temperature",
89
+ "thinking", "tools", "tool_choice", "stop_sequences",
90
+ "top_p", "top_k", "metadata",
91
+ })
92
+
93
+
94
+ def _apply_api_params(kwargs: dict[str, Any], api_params: dict[str, Any] | None,
95
+ known: frozenset[str]) -> None:
96
+ """将 api_params 合并到 kwargs:已知字段直接入参,未知入 extra_body。"""
97
+ if not api_params:
98
+ return
99
+ for k, v in api_params.items():
100
+ if k in known:
101
+ kwargs[k] = v
102
+ else:
103
+ kwargs.setdefault("extra_body", {})[k] = v
104
+
105
+
106
+ def _build_system(system: str | None, notdo: list[str] | None) -> str | None:
107
+ """拼装 system prompt:基础 system + 否定性约束(notdo)。
108
+
109
+ notdo 是「进一步约束,注入提示词」(见 spec harness 三层 prompt),这里作为 system 的一部分
110
+ 原生注入,使模型在生成时就受其约束。
111
+ """
112
+ parts: list[str] = []
113
+ if system:
114
+ parts.append(system)
115
+ if notdo:
116
+ parts.append("不要做以下事项:\n" + "\n".join(f"- {n}" for n in notdo))
117
+ return "\n\n".join(parts) if parts else None
118
+
119
+
120
+ def _safe_on_token(on_token: Callable[[str], None] | None, chunk: str) -> None:
121
+ """调用 on_token,回调异常不得影响主流程(观测者不应破坏调用)。"""
122
+ if on_token is None or not chunk:
123
+ return
124
+ try:
125
+ on_token(chunk)
126
+ except Exception:
127
+ log.exception("on_token 回调异常;已忽略")
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Anthropic 客户端
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ class AnthropicClient:
136
+ """Anthropic Claude API 客户端。
137
+
138
+ 结构化输出经强制 tool-use 原生实现(Anthropic 无 JSON mode);扩展思考经 ``thinking``
139
+ 参数原生启用。
140
+ """
141
+
142
+ def __init__(self, config: LLMConfig) -> None:
143
+ self.config = config
144
+ try:
145
+ from anthropic import AsyncAnthropic
146
+ self._client = AsyncAnthropic(api_key=config.api_key)
147
+ self._ready = True
148
+ except ImportError:
149
+ log.error("anthropic 包未安装,请执行: pip install anthropic")
150
+ self._ready = False
151
+ self._client = None
152
+ except Exception as exc:
153
+ log.error("Anthropic 客户端初始化失败: %s", exc)
154
+ self._ready = False
155
+ self._client = None
156
+
157
+ @property
158
+ def ready(self) -> bool:
159
+ return self._ready
160
+
161
+ def _require_ready(self) -> None:
162
+ if not self._ready:
163
+ raise LLMError("Anthropic 客户端未就绪(anthropic 包未安装或初始化失败)")
164
+
165
+ def _thinking_param(self, think: bool | dict | None) -> dict | None:
166
+ """把 think 配置转为 Anthropic ``thinking`` 参数。
167
+
168
+ Anthropic 扩展思考要求 ``budget_tokens < max_tokens``;启用思考时 temperature 须为 1
169
+ (由调用处省略 temperature 实现)。
170
+ """
171
+ if not think:
172
+ return None
173
+ if isinstance(think, dict):
174
+ budget = int(think.get("budget_tokens", 4096))
175
+ else:
176
+ # bool True:给保守默认,预留输出空间
177
+ budget = min(self.config.max_tokens - 1024, 8192)
178
+ budget = max(1024, budget)
179
+ if budget >= self.config.max_tokens:
180
+ raise LLMError(
181
+ f"think budget_tokens({budget}) 须小于 max_tokens({self.config.max_tokens})"
182
+ )
183
+ return {"type": "enabled", "budget_tokens": budget}
184
+
185
+ def _structured_tool(self, output_format: dict[str, Any]) -> tuple[list[dict], dict]:
186
+ """把 output_format 转为 Anthropic 强制 tool-use 参数。
187
+
188
+ output_format 形如 ``{"name": str, "description": str, "schema": <JSON schema>}``。
189
+ 强制模型调用该 tool,其 input 即结构化输出(content 取其 JSON)。
190
+ """
191
+ name = output_format.get("name", "structured_output")
192
+ schema = (
193
+ output_format.get("schema")
194
+ or output_format.get("input_schema")
195
+ or output_format
196
+ )
197
+ tool = {
198
+ "name": name,
199
+ "description": output_format.get("description", "Return structured output."),
200
+ "input_schema": schema,
201
+ }
202
+ return [tool], {"type": "tool", "name": name}
203
+
204
+ async def complete(
205
+ self,
206
+ prompt: str,
207
+ *,
208
+ system: str | None = None,
209
+ model: str | None = None,
210
+ temperature: float | None = None,
211
+ think: bool | dict | None = None,
212
+ output_format: dict[str, Any] | None = None,
213
+ notdo: list[str] | None = None,
214
+ on_token: Callable[[str], None] | None = None,
215
+ api_params: dict[str, Any] | None = None,
216
+ ) -> LLMResponse:
217
+ """单轮调用入口(harness body 用)。
218
+
219
+ - ``prompt``:三层渲染后的用户提示词
220
+ - ``model``/``temperature``/``think``:按调用覆盖,缺省回落 config
221
+ - ``output_format``:原生结构化输出(强制 tool-use)
222
+ - ``on_token``:流式 token 回调(提供时走流式接口)
223
+ - ``api_params``:透传给 SDK 的额外参数(已知字段入 kwargs,未知入 extra_body)
224
+ """
225
+ self._require_ready()
226
+ model = model or self.config.model
227
+ temperature = self.config.temperature if temperature is None else temperature
228
+ thinking = self._thinking_param(think if think is not None else self.config.model_info(model).get("think"))
229
+ # 框架级 system_rules 注入到 system prompt 最前面
230
+ full_system = None
231
+ if self.config.system_rules:
232
+ full_system = self.config.system_rules
233
+ if system:
234
+ full_system += "\n\n" + system
235
+ elif system:
236
+ full_system = system
237
+ sys_prompt = _build_system(full_system, notdo)
238
+
239
+ kwargs: dict[str, Any] = {
240
+ "model": model,
241
+ "max_tokens": self.config.max_tokens,
242
+ "messages": [{"role": "user", "content": prompt}],
243
+ }
244
+ if sys_prompt:
245
+ kwargs["system"] = sys_prompt
246
+ # 启用思考时 temperature 必须为 1(省略即默认 1.0)
247
+ if temperature is not None and not thinking:
248
+ kwargs["temperature"] = temperature
249
+ if thinking:
250
+ kwargs["thinking"] = thinking
251
+
252
+ forced_tool: str | None = None
253
+ if output_format:
254
+ tools, tool_choice = self._structured_tool(output_format)
255
+ kwargs["tools"] = tools
256
+ kwargs["tool_choice"] = tool_choice
257
+ forced_tool = tools[0]["name"]
258
+
259
+ _apply_api_params(kwargs, api_params, _KNOWN_ANTHROPIC_PARAMS)
260
+
261
+ try:
262
+ if on_token:
263
+ content, tool_calls, usage, finish = await self._stream(kwargs, forced_tool, on_token)
264
+ else:
265
+ content, tool_calls, usage, finish = await self._nonstream(kwargs, forced_tool)
266
+ except LLMError:
267
+ raise
268
+ except Exception as exc:
269
+ raise LLMError(f"Anthropic API 调用失败: {exc}") from exc
270
+ return LLMResponse(content=content, tool_calls=tool_calls, usage=usage, finish_reason=finish)
271
+
272
+ async def _nonstream(self, kwargs: dict, forced_tool: str | None) -> tuple:
273
+ response = await self._client.messages.create(**kwargs)
274
+ content = ""
275
+ tool_calls: list[dict[str, Any]] = []
276
+ for block in response.content:
277
+ if block.type == "text":
278
+ content += block.text
279
+ elif block.type == "tool_use":
280
+ tool_calls.append({"id": block.id, "name": block.name, "arguments": block.input})
281
+ if forced_tool and block.name == forced_tool:
282
+ content = json.dumps(block.input, ensure_ascii=False)
283
+ usage = {
284
+ "input_tokens": response.usage.input_tokens or 0,
285
+ "output_tokens": response.usage.output_tokens or 0,
286
+ }
287
+ return content, tool_calls, usage, response.stop_reason
288
+
289
+ async def _stream(self, kwargs: dict, forced_tool: str | None, on_token) -> tuple:
290
+ content = ""
291
+ tool_calls: list[dict[str, Any]] = []
292
+ async with self._client.messages.stream(**kwargs) as stream:
293
+ async for text in stream.text_stream:
294
+ content += text
295
+ _safe_on_token(on_token, text)
296
+ final = await stream.get_final_message()
297
+ for block in final.content:
298
+ if block.type == "tool_use":
299
+ tool_calls.append({"id": block.id, "name": block.name, "arguments": block.input})
300
+ if forced_tool and block.name == forced_tool:
301
+ content = json.dumps(block.input, ensure_ascii=False)
302
+ usage = {
303
+ "input_tokens": final.usage.input_tokens or 0,
304
+ "output_tokens": final.usage.output_tokens or 0,
305
+ }
306
+ return content, tool_calls, usage, final.stop_reason
307
+
308
+ # --- 多轮底层接口(保留供对齐检查 / spec 翻译等 LLM 调用复用) -------------
309
+
310
+ def _convert_messages(self, messages: list[Message]) -> tuple[str | None, list[dict[str, Any]]]:
311
+ """分离 system 消息并转换其余消息。"""
312
+ system_prompt = None
313
+ api_messages = []
314
+ for msg in messages:
315
+ if msg.role == "system":
316
+ system_prompt = msg.content
317
+ else:
318
+ api_messages.append({"role": msg.role, "content": msg.content})
319
+ return system_prompt, api_messages
320
+
321
+ def _tools_to_anthropic(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
322
+ return [
323
+ {
324
+ "name": t["name"],
325
+ "description": t.get("description", ""),
326
+ "input_schema": t.get("input_schema", t.get("parameters", {})),
327
+ }
328
+ for t in tools
329
+ ]
330
+
331
+ async def chat(
332
+ self,
333
+ messages: list[Message],
334
+ tools: list[dict[str, Any]] | None = None,
335
+ ) -> LLMResponse:
336
+ """多轮聊天(底层接口)。调用失败抛 LLMError。"""
337
+ self._require_ready()
338
+ system_prompt, api_messages = self._convert_messages(messages)
339
+ kwargs: dict[str, Any] = {
340
+ "model": self.config.model,
341
+ "messages": api_messages,
342
+ "max_tokens": self.config.max_tokens,
343
+ "temperature": self.config.temperature,
344
+ }
345
+ if system_prompt:
346
+ kwargs["system"] = system_prompt
347
+ if tools:
348
+ kwargs["tools"] = self._tools_to_anthropic(tools)
349
+
350
+ try:
351
+ response = await self._client.messages.create(**kwargs)
352
+ except Exception as exc:
353
+ raise LLMError(f"Anthropic API 调用失败: {exc}") from exc
354
+
355
+ content = ""
356
+ tool_calls = []
357
+ for block in response.content:
358
+ if block.type == "text":
359
+ content += block.text
360
+ elif block.type == "tool_use":
361
+ tool_calls.append({
362
+ "id": block.id,
363
+ "name": block.name,
364
+ "arguments": block.input,
365
+ })
366
+
367
+ return LLMResponse(
368
+ content=content,
369
+ tool_calls=tool_calls,
370
+ usage={
371
+ "input_tokens": response.usage.input_tokens or 0,
372
+ "output_tokens": response.usage.output_tokens or 0,
373
+ },
374
+ finish_reason=response.stop_reason,
375
+ )
376
+
377
+ async def close(self) -> None:
378
+ if self._client is not None:
379
+ await self._client.close()
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # OpenAI 兼容客户端
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ class OpenAIClient:
388
+ """OpenAI 及兼容接口客户端。
389
+
390
+ 结构化输出经 ``response_format`` 原生实现;扩展思考经 ``reasoning_effort`` 原生启用
391
+ (仅 reasoning 模型 o1/o3/o4 系列)。
392
+ """
393
+
394
+ def __init__(self, config: LLMConfig) -> None:
395
+ self.config = config
396
+ try:
397
+ from openai import AsyncOpenAI
398
+ kwargs: dict[str, Any] = {"api_key": config.api_key}
399
+ if config.base_url:
400
+ kwargs["base_url"] = config.base_url
401
+ self._client = AsyncOpenAI(**kwargs)
402
+ self._ready = True
403
+ except ImportError:
404
+ log.error("openai 包未安装,请执行: pip install openai")
405
+ self._ready = False
406
+ self._client = None
407
+ except Exception as exc:
408
+ log.error("OpenAI 客户端初始化失败: %s", exc)
409
+ self._ready = False
410
+ self._client = None
411
+
412
+ @property
413
+ def ready(self) -> bool:
414
+ return self._ready
415
+
416
+ def _require_ready(self) -> None:
417
+ if not self._ready:
418
+ raise LLMError("OpenAI 客户端未就绪(openai 包未安装或初始化失败)")
419
+
420
+ @staticmethod
421
+ def _is_reasoning_model(model: str) -> bool:
422
+ """o1/o3/o4 系列 reasoning 模型:不支持 temperature,用 max_completion_tokens / reasoning_effort。"""
423
+ return bool(re.match(r"^o[134]", model.lower()))
424
+
425
+ async def complete(
426
+ self,
427
+ prompt: str,
428
+ *,
429
+ system: str | None = None,
430
+ model: str | None = None,
431
+ temperature: float | None = None,
432
+ think: bool | dict | None = None,
433
+ output_format: dict[str, Any] | None = None,
434
+ notdo: list[str] | None = None,
435
+ on_token: Callable[[str], None] | None = None,
436
+ api_params: dict[str, Any] | None = None,
437
+ ) -> LLMResponse:
438
+ """单轮调用入口(harness body 用)。
439
+
440
+ - ``output_format``:原样作为 ``response_format`` 传入(如 ``{"type": "json_object"}``
441
+ 或 ``{"type": "json_schema", "json_schema": {...}}``)
442
+ - ``think``:reasoning 模型映射为 ``reasoning_effort``("low"/"medium"/"high",
443
+ dict 可指定 ``effort``;非 reasoning 模型忽略)
444
+ - ``api_params``:透传给 SDK 的额外参数(已知字段入 kwargs,未知入 extra_body)
445
+ """
446
+ self._require_ready()
447
+ model = model or self.config.model
448
+ temperature = self.config.temperature if temperature is None else temperature
449
+ think = think if think is not None else self.config.model_info(model).get("think")
450
+ # 框架级 system_rules 注入到 system prompt 最前面
451
+ full_system = None
452
+ if self.config.system_rules:
453
+ full_system = self.config.system_rules
454
+ if system:
455
+ full_system += "\n\n" + system
456
+ elif system:
457
+ full_system = system
458
+ sys_prompt = _build_system(full_system, notdo)
459
+ reasoning = self._is_reasoning_model(model)
460
+
461
+ messages: list[dict[str, Any]] = []
462
+ if sys_prompt:
463
+ messages.append({"role": "system", "content": sys_prompt})
464
+ messages.append({"role": "user", "content": prompt})
465
+
466
+ kwargs: dict[str, Any] = {"model": model, "messages": messages}
467
+ # reasoning 模型用 max_completion_tokens,且不支持 temperature
468
+ if reasoning:
469
+ kwargs["max_completion_tokens"] = self.config.max_tokens
470
+ else:
471
+ kwargs["max_tokens"] = self.config.max_tokens
472
+ kwargs["temperature"] = temperature
473
+ if think and reasoning:
474
+ if isinstance(think, dict):
475
+ kwargs["reasoning_effort"] = think.get("effort", "medium")
476
+ else:
477
+ kwargs["reasoning_effort"] = "medium"
478
+ if output_format:
479
+ kwargs["response_format"] = output_format
480
+
481
+ _apply_api_params(kwargs, api_params, _KNOWN_OPENAI_PARAMS)
482
+
483
+ try:
484
+ if on_token:
485
+ content, tool_calls, usage, finish = await self._stream(kwargs, on_token)
486
+ else:
487
+ content, tool_calls, usage, finish = await self._nonstream(kwargs)
488
+ except LLMError:
489
+ raise
490
+ except Exception as exc:
491
+ raise LLMError(f"OpenAI API 调用失败: {exc}") from exc
492
+ return LLMResponse(content=content, tool_calls=tool_calls, usage=usage, finish_reason=finish)
493
+
494
+ async def _nonstream(self, kwargs: dict) -> tuple:
495
+ response = await self._client.chat.completions.create(**kwargs)
496
+ choice = response.choices[0]
497
+ content = choice.message.content or ""
498
+ tool_calls: list[dict[str, Any]] = []
499
+ if choice.message.tool_calls:
500
+ for tc in choice.message.tool_calls:
501
+ try:
502
+ args = json.loads(tc.function.arguments)
503
+ except (json.JSONDecodeError, TypeError):
504
+ args = {}
505
+ tool_calls.append({"id": tc.id, "name": tc.function.name, "arguments": args})
506
+ usage = {
507
+ "input_tokens": response.usage.prompt_tokens if response.usage else 0,
508
+ "output_tokens": response.usage.completion_tokens if response.usage else 0,
509
+ }
510
+ return content, tool_calls, usage, choice.finish_reason
511
+
512
+ async def _stream(self, kwargs: dict, on_token) -> tuple:
513
+ kwargs["stream"] = True
514
+ # stream_options 仅官方 OpenAI 必然支持;兼容接口(base_url 非空)省略以免被拒
515
+ if not self.config.base_url:
516
+ kwargs["stream_options"] = {"include_usage": True}
517
+ content = ""
518
+ tool_calls: list[dict[str, Any]] = []
519
+ usage: dict[str, int] = {}
520
+ finish: str | None = None
521
+ stream = await self._client.chat.completions.create(**kwargs)
522
+ async for chunk in stream:
523
+ if chunk.usage:
524
+ usage = {
525
+ "input_tokens": chunk.usage.prompt_tokens or 0,
526
+ "output_tokens": chunk.usage.completion_tokens or 0,
527
+ }
528
+ if not chunk.choices:
529
+ continue
530
+ delta = chunk.choices[0].delta
531
+ if delta.content:
532
+ content += delta.content
533
+ _safe_on_token(on_token, delta.content)
534
+ if chunk.choices[0].finish_reason:
535
+ finish = chunk.choices[0].finish_reason
536
+ return content, tool_calls, usage, finish
537
+
538
+ # --- 多轮底层接口 -------------------------------------------------------
539
+
540
+ def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
541
+ api_messages = []
542
+ for msg in messages:
543
+ if msg.role == "system":
544
+ api_messages.append({"role": "system", "content": msg.content})
545
+ elif msg.role == "assistant" and msg.tool_calls:
546
+ api_messages.append({
547
+ "role": "assistant",
548
+ "content": msg.content or None,
549
+ "tool_calls": [
550
+ {
551
+ "id": tc["id"],
552
+ "type": "function",
553
+ "function": {
554
+ "name": tc["name"],
555
+ "arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False),
556
+ },
557
+ }
558
+ for tc in msg.tool_calls
559
+ ],
560
+ })
561
+ elif msg.role == "tool":
562
+ api_messages.append({
563
+ "role": "tool",
564
+ "tool_call_id": msg.tool_call_id or "",
565
+ "content": msg.content,
566
+ })
567
+ else:
568
+ api_messages.append({"role": msg.role, "content": msg.content})
569
+ return api_messages
570
+
571
+ def _tools_to_openai(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
572
+ return [
573
+ {
574
+ "type": "function",
575
+ "function": {
576
+ "name": t["name"],
577
+ "description": t.get("description", ""),
578
+ "parameters": t.get("input_schema", t.get("parameters", {})),
579
+ },
580
+ }
581
+ for t in tools
582
+ ]
583
+
584
+ async def chat(
585
+ self,
586
+ messages: list[Message],
587
+ tools: list[dict[str, Any]] | None = None,
588
+ ) -> LLMResponse:
589
+ """多轮聊天(底层接口)。调用失败抛 LLMError。"""
590
+ self._require_ready()
591
+ api_messages = self._convert_messages(messages)
592
+ kwargs: dict[str, Any] = {
593
+ "model": self.config.model,
594
+ "messages": api_messages,
595
+ }
596
+ if self._is_reasoning_model(self.config.model):
597
+ kwargs["max_completion_tokens"] = self.config.max_tokens
598
+ else:
599
+ kwargs["max_tokens"] = self.config.max_tokens
600
+ kwargs["temperature"] = self.config.temperature
601
+ if tools:
602
+ kwargs["tools"] = self._tools_to_openai(tools)
603
+
604
+ try:
605
+ response = await self._client.chat.completions.create(**kwargs)
606
+ except Exception as exc:
607
+ raise LLMError(f"OpenAI API 调用失败: {exc}") from exc
608
+
609
+ choice = response.choices[0]
610
+ content = choice.message.content or ""
611
+ tool_calls = []
612
+ if choice.message.tool_calls:
613
+ for tc in choice.message.tool_calls:
614
+ try:
615
+ args = json.loads(tc.function.arguments)
616
+ except (json.JSONDecodeError, TypeError):
617
+ args = {}
618
+ tool_calls.append({
619
+ "id": tc.id,
620
+ "name": tc.function.name,
621
+ "arguments": args,
622
+ })
623
+
624
+ return LLMResponse(
625
+ content=content,
626
+ tool_calls=tool_calls,
627
+ usage={
628
+ "input_tokens": response.usage.prompt_tokens if response.usage else 0,
629
+ "output_tokens": response.usage.completion_tokens if response.usage else 0,
630
+ },
631
+ finish_reason=choice.finish_reason,
632
+ )
633
+
634
+ async def close(self) -> None:
635
+ if self._client is not None:
636
+ await self._client.close()
637
+
638
+
639
+ # ---------------------------------------------------------------------------
640
+ # 客户端工厂
641
+ # ---------------------------------------------------------------------------
642
+
643
+
644
+ def create_llm_client(config: LLMConfig):
645
+ """根据配置创建合适的 LLM 客户端。"""
646
+ if config.provider == "anthropic":
647
+ return AnthropicClient(config)
648
+ elif config.provider in ("openai", "openai-compatible"):
649
+ return OpenAIClient(config)
650
+ else:
651
+ # 默认尝试 OpenAI 格式(最常见)
652
+ log.warning("未知 provider '%s',回退到 OpenAI 兼容客户端", config.provider)
653
+ config.provider = "openai-compatible"
654
+ return OpenAIClient(config)