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 +21 -0
- llm/client.py +654 -0
- llm/config.py +213 -0
- module_harness/__init__.py +201 -0
- module_harness/align.py +39 -0
- module_harness/builtins.py +29 -0
- module_harness/checkpoint.py +336 -0
- module_harness/cli.py +1492 -0
- module_harness/command.py +115 -0
- module_harness/config.py +95 -0
- module_harness/consistency.py +123 -0
- module_harness/entry.py +74 -0
- module_harness/events.py +149 -0
- module_harness/feed.py +197 -0
- module_harness/graph_builder.py +334 -0
- module_harness/harness.py +181 -0
- module_harness/loader.py +215 -0
- module_harness/module.py +452 -0
- module_harness/outputfmt.py +139 -0
- module_harness/prompt.py +84 -0
- module_harness/query.py +216 -0
- module_harness/registry.py +180 -0
- module_harness/scaffold.py +404 -0
- module_harness/spec.py +209 -0
- module_harness/status.py +96 -0
- module_harness/store.py +482 -0
- module_harness/submodule.py +268 -0
- module_harness/templates/builtin/codereview.json +32 -0
- module_harness/templates/builtin/docwrite.json +30 -0
- module_harness/templates/builtin/summarize.json +24 -0
- module_harness/templates/builtin/translate.json +27 -0
- module_harness/translator.py +314 -0
- specmodule-0.1.0.dist-info/METADATA +321 -0
- specmodule-0.1.0.dist-info/RECORD +38 -0
- specmodule-0.1.0.dist-info/WHEEL +5 -0
- specmodule-0.1.0.dist-info/entry_points.txt +2 -0
- specmodule-0.1.0.dist-info/licenses/LICENSE +21 -0
- specmodule-0.1.0.dist-info/top_level.txt +2 -0
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)
|