qi-coding-agent 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.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/llm.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""LLM 客户端(llm.py):协议 + litellm 实现(A4)。
|
|
2
|
+
|
|
3
|
+
一次 chat 返回:文本 或 工具调用列表。Router 与 Runner 共用(按 models.json 的 default/router)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from .config import ResolvedModel
|
|
15
|
+
from .auth import AuthStore, resolve_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ToolCallOut:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
args: dict
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ChatMessage:
|
|
27
|
+
role: str # system|user|assistant|tool
|
|
28
|
+
content: str = ""
|
|
29
|
+
tool_calls: list[ToolCallOut] = field(default_factory=list)
|
|
30
|
+
tool_call_id: str | None = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
d: dict = {"role": self.role, "content": self.content}
|
|
34
|
+
if self.tool_calls:
|
|
35
|
+
d["tool_calls"] = [
|
|
36
|
+
{"id": c.id, "type": "function",
|
|
37
|
+
"function": {"name": c.name, "arguments": json.dumps(c.args, ensure_ascii=False)}}
|
|
38
|
+
for c in self.tool_calls
|
|
39
|
+
]
|
|
40
|
+
if self.tool_call_id:
|
|
41
|
+
d["tool_call_id"] = self.tool_call_id
|
|
42
|
+
return d
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ChatResponse:
|
|
47
|
+
"""一次非流式回复。`reasoning` = 思考内容(pi 的 thinking block),不算回答。"""
|
|
48
|
+
|
|
49
|
+
text: str = ""
|
|
50
|
+
tool_calls: list[ToolCallOut] = field(default_factory=list)
|
|
51
|
+
usage: dict = field(default_factory=dict)
|
|
52
|
+
reasoning: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LLMClient(Protocol):
|
|
56
|
+
"""**最小**契约:只要求 `chat()`。
|
|
57
|
+
|
|
58
|
+
流式是**可选增强**(见 `StreamingLLMClient` 与 `stream_llm`):第三方实现与测试替身
|
|
59
|
+
只写 chat 也能跑,代价是前端拿到单块文本而非逐字。
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
async def chat(self, messages: list[ChatMessage], tools: list[dict] | None = None,
|
|
63
|
+
temperature: float | None = None) -> ChatResponse: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StreamingLLMClient(Protocol):
|
|
67
|
+
"""可选增强:提供 `astream()` 即可逐字流式。
|
|
68
|
+
|
|
69
|
+
实现方**不要**在工厂函数里起后台资源;它是一个普通的异步生成器。
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def astream(self, messages: list[ChatMessage], tools: list[dict] | None = None,
|
|
73
|
+
temperature: float | None = None) -> AsyncIterator[LLMDelta]: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class ThinkingLLMClient(Protocol):
|
|
78
|
+
"""可选增强:支持思考级别(pi 的 thinkingLevel → `reasoning_effort`)。
|
|
79
|
+
|
|
80
|
+
单独一个协议而不是塞进 `LLMClient`:很多实现(测试替身/第三方)没有思考概念,
|
|
81
|
+
不该被强迫实现一个空属性。用 `isinstance` 在运行期判定是否真的可设。
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
thinking_level: str
|
|
85
|
+
reasoning_dropped: bool
|
|
86
|
+
"""provider 拒过 reasoning_effort 吗(UI 据此提示一次)。"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class LLMDelta:
|
|
91
|
+
"""流式增量。契约:**0+ 个文本块,然后恰好一个 `finished` 块**。
|
|
92
|
+
|
|
93
|
+
最终文本 = 所有文本块拼接;`tool_calls` 与 `usage` **只在** `finished` 块上。
|
|
94
|
+
这一形状让消费者(AgentRunner)只需处理一种情况,而不是"有的实现流式、有的不流式"。
|
|
95
|
+
|
|
96
|
+
`reasoning` 是思考内容(pi 的 thinking block):与 `text` 分开流式,**不算**最终回答。
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
text: str = ""
|
|
100
|
+
reasoning: str = ""
|
|
101
|
+
finished: bool = False
|
|
102
|
+
tool_calls: list[ToolCallOut] = field(default_factory=list)
|
|
103
|
+
usage: dict = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
THINKING_LEVELS: tuple[str, ...] = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
107
|
+
"""思考级别(对齐 pi 的 thinkingLevel)。`off` = 不请求思考。"""
|
|
108
|
+
|
|
109
|
+
DEFAULT_THINKING_LEVEL = "medium"
|
|
110
|
+
"""没配任何级别时的默认档(pi `core/defaults.js` 的 `DEFAULT_THINKING_LEVEL`)。
|
|
111
|
+
|
|
112
|
+
注意与 `normalize_thinking_level` 的区别:后者是**写歪的值的兑底**(一律 `off`),
|
|
113
|
+
这里是**什么都没写时的生效值** —— pi 两处行为不同,不要拿一个去顶另一个。"""
|
|
114
|
+
|
|
115
|
+
# qi 经 litellm 传 reasoning_effort;litellm / 各 provider 只认 minimal/low/medium/high,
|
|
116
|
+
# 所以 xhigh / max 收敛到 high(pi 的 provider 侧有原生 xhigh/max,litellm 没有)。
|
|
117
|
+
_EFFORT_MAP = {"minimal": "minimal", "low": "low", "medium": "medium",
|
|
118
|
+
"high": "high", "xhigh": "high", "max": "high"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_thinking_level(value: str | None) -> str:
|
|
122
|
+
"""级别归一:未知值一律当 `off`(不能因为 settings 里写错就让请求带上怪参数)。
|
|
123
|
+
|
|
124
|
+
"没给值"与"给了个不认识的值"在调用点合流 —— 但**默认档**的判断不在这里(那是
|
|
125
|
+
`DEFAULT_THINKING_LEVEL` 的事,由 runtime 装配时决定)。
|
|
126
|
+
"""
|
|
127
|
+
level = (value or "").strip().lower()
|
|
128
|
+
return level if level in THINKING_LEVELS else "off"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reasoning_text_of(obj: object) -> str:
|
|
132
|
+
"""从 litellm 的 delta / message 里取思考内容。
|
|
133
|
+
|
|
134
|
+
各 provider 字段名不一:`reasoning_content`(DeepSeek/Qwen 系)、`reasoning`
|
|
135
|
+
(OpenAI 兼容网关)、`thinking`(Anthropic 系,可能是块列表)。统一成字符串。
|
|
136
|
+
"""
|
|
137
|
+
for attr in ("reasoning_content", "reasoning", "thinking"):
|
|
138
|
+
value = getattr(obj, attr, None)
|
|
139
|
+
if value is None and isinstance(obj, dict):
|
|
140
|
+
value = obj.get(attr)
|
|
141
|
+
if not value:
|
|
142
|
+
continue
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, list): # Anthropic 风格块列表
|
|
146
|
+
parts = []
|
|
147
|
+
for item in value:
|
|
148
|
+
if isinstance(item, str):
|
|
149
|
+
parts.append(item)
|
|
150
|
+
elif isinstance(item, dict):
|
|
151
|
+
parts.append(str(item.get("thinking") or item.get("text") or ""))
|
|
152
|
+
else:
|
|
153
|
+
parts.append(str(getattr(item, "thinking", "") or ""))
|
|
154
|
+
return "".join(parts)
|
|
155
|
+
return str(value)
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def chat_as_stream(llm: LLMClient, messages: list[ChatMessage],
|
|
160
|
+
tools: list[dict] | None = None,
|
|
161
|
+
temperature: float | None = None) -> AsyncIterator[LLMDelta]:
|
|
162
|
+
"""把一次非流式 `chat()` 合成为流:整段文本作为**单块** + 一个终止块。
|
|
163
|
+
|
|
164
|
+
两处用途:(a) 实现方只有 chat(第三方/测试替身);(b) 流式打不开时的降级。
|
|
165
|
+
"""
|
|
166
|
+
resp = await llm.chat(messages, tools=tools, temperature=temperature)
|
|
167
|
+
if resp.reasoning:
|
|
168
|
+
yield LLMDelta(reasoning=resp.reasoning)
|
|
169
|
+
if resp.text:
|
|
170
|
+
yield LLMDelta(text=resp.text)
|
|
171
|
+
yield LLMDelta(finished=True, tool_calls=resp.tool_calls or [], usage=resp.usage or {})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def stream_llm(llm: LLMClient, messages: list[ChatMessage],
|
|
175
|
+
tools: list[dict] | None = None,
|
|
176
|
+
temperature: float | None = None) -> AsyncGenerator[LLMDelta, None]:
|
|
177
|
+
"""统一调用入口:实现方有 `astream()` 就流式,否则用 chat 合成单块。
|
|
178
|
+
|
|
179
|
+
返回**异步生成器**(而非裸 AsyncIterator):调用方需要在中断时 `aclose()` 它,
|
|
180
|
+
把底层请求就地关掉。
|
|
181
|
+
"""
|
|
182
|
+
astream = getattr(llm, "astream", None)
|
|
183
|
+
if astream is None:
|
|
184
|
+
async for delta in chat_as_stream(llm, messages, tools, temperature):
|
|
185
|
+
yield delta
|
|
186
|
+
return
|
|
187
|
+
async for delta in astream(messages, tools=tools, temperature=temperature):
|
|
188
|
+
yield delta
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# litellm 前缀按 api 类型推导(不预置 provider 名录)
|
|
192
|
+
_API_PREFIX = {
|
|
193
|
+
"anthropic-messages": "anthropic",
|
|
194
|
+
"google-generative-ai": "gemini",
|
|
195
|
+
"openai-responses": "openai",
|
|
196
|
+
"openai-completions": "openai",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def litellm_model_name(spec: ResolvedModel) -> str:
|
|
201
|
+
"""把 provider + api 映射为 litellm 的 model 字符串(不依赖 provider 名录)。"""
|
|
202
|
+
prefix = _API_PREFIX.get(spec.api, "openai")
|
|
203
|
+
return f"{prefix}/{spec.model}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_USAGE_FALLBACK_KEYS = ("prompt_tokens", "completion_tokens", "total_tokens")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def usage_to_dict(usage: object) -> dict:
|
|
210
|
+
"""把 provider 的 usage 对象归一为纯 dict。
|
|
211
|
+
|
|
212
|
+
必要性:litellm 返回的是 pydantic 对象。直接放进 `AgentEvent.data` 会让
|
|
213
|
+
`json.dumps` 失败(SSE 与 `--mode json` 都要序列化它),也取不到字段。
|
|
214
|
+
"""
|
|
215
|
+
if usage is None:
|
|
216
|
+
return {}
|
|
217
|
+
if isinstance(usage, dict):
|
|
218
|
+
return usage
|
|
219
|
+
for attr in ("model_dump", "dict"): # pydantic v2 / v1
|
|
220
|
+
dump = getattr(usage, attr, None)
|
|
221
|
+
if callable(dump):
|
|
222
|
+
with contextlib.suppress(Exception):
|
|
223
|
+
data = dump()
|
|
224
|
+
if isinstance(data, dict):
|
|
225
|
+
return data
|
|
226
|
+
return {key: getattr(usage, key) for key in _USAGE_FALLBACK_KEYS
|
|
227
|
+
if getattr(usage, key, None) is not None}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def merge_tool_call_delta(merged: dict[int, dict], tc: object) -> None:
|
|
231
|
+
"""把一片 `tool_call` 增量并入累加器(按 `index` 归并)。
|
|
232
|
+
|
|
233
|
+
流式下工具调用是被拆开发来的:首片带 `id` + `name`,后续片只带 `arguments` 的碎片,
|
|
234
|
+
而且多个并行调用会**交错的**发。因此必须按 index 归并、参数**拼接**。
|
|
235
|
+
"""
|
|
236
|
+
idx = getattr(tc, "index", None)
|
|
237
|
+
key = idx if isinstance(idx, int) else len(merged)
|
|
238
|
+
slot = merged.setdefault(key, {"id": None, "name": "", "args": ""})
|
|
239
|
+
tc_id = getattr(tc, "id", None)
|
|
240
|
+
if tc_id:
|
|
241
|
+
slot["id"] = tc_id
|
|
242
|
+
fn = getattr(tc, "function", None)
|
|
243
|
+
if fn is None:
|
|
244
|
+
return
|
|
245
|
+
name = getattr(fn, "name", None)
|
|
246
|
+
if name:
|
|
247
|
+
slot["name"] = name
|
|
248
|
+
args = getattr(fn, "arguments", None)
|
|
249
|
+
if args:
|
|
250
|
+
slot["args"] = slot["args"] + args
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def merged_tool_calls(merged: dict[int, dict]) -> list[ToolCallOut]:
|
|
254
|
+
"""把累加器收成 ToolCallOut 列表。
|
|
255
|
+
|
|
256
|
+
无名片段(部分 provider 会发空壳)跳过;参数 JSON 坏了退化为 `{}`,
|
|
257
|
+
而不是把整轮打挂——模型下一轮会看到 "未知参数" 并自行调整。
|
|
258
|
+
"""
|
|
259
|
+
calls: list[ToolCallOut] = []
|
|
260
|
+
for i, slot in sorted(merged.items()):
|
|
261
|
+
if not slot["name"]:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
args = json.loads(slot["args"] or "{}")
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
args = {}
|
|
267
|
+
calls.append(ToolCallOut(id=slot["id"] or f"call_{i}", name=slot["name"], args=args))
|
|
268
|
+
return calls
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def provider_retry_params(retry: dict | None) -> dict:
|
|
272
|
+
"""`settings.json` 的 `retry.provider` → litellm 的**请求级**参数(对齐 pi 的形态)。
|
|
273
|
+
|
|
274
|
+
pi 把超时与客户端重试放在 **provider/SDK 那一层**(`retry.provider.timeoutMs` /
|
|
275
|
+
`maxRetries`),而不是套在整个回合外面 —— 一次慢请求只重试它自己,不会把整轮打成失败。
|
|
276
|
+
qi 的 settings.json 与 pi 同名同形,所以直接读那两个键。
|
|
277
|
+
|
|
278
|
+
未映射:pi 的 `maxRetryDelayMs`(litellm 没有对应参数)。
|
|
279
|
+
"""
|
|
280
|
+
if not isinstance(retry, dict):
|
|
281
|
+
return {}
|
|
282
|
+
provider = retry.get("provider")
|
|
283
|
+
if not isinstance(provider, dict):
|
|
284
|
+
return {}
|
|
285
|
+
out: dict = {}
|
|
286
|
+
timeout_ms = provider.get("timeoutMs")
|
|
287
|
+
if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) and timeout_ms > 0:
|
|
288
|
+
out["timeout"] = timeout_ms / 1000 # pi 用毫秒,litellm 用秒
|
|
289
|
+
retries = provider.get("maxRetries")
|
|
290
|
+
if isinstance(retries, int) and not isinstance(retries, bool) and retries >= 0:
|
|
291
|
+
out["num_retries"] = retries
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _response_status_headers(resp: Any) -> tuple[int, dict[str, str]]:
|
|
296
|
+
"""从 litellm 的返回里尽最大努力取 HTTP 状态与响应头。
|
|
297
|
+
|
|
298
|
+
litellm 与底层 httpx 响应的接缝各版本不同(`_response` / `response`),所以逐层
|
|
299
|
+
`getattr` —— 取不到就给 `(0, {})`:通知型事件不该因拿不到元数据而报错。
|
|
300
|
+
"""
|
|
301
|
+
raw = getattr(resp, "_response", None) or getattr(resp, "response", None)
|
|
302
|
+
status = getattr(raw, "status_code", 0)
|
|
303
|
+
try:
|
|
304
|
+
headers = {str(k): str(v) for k, v in dict(getattr(raw, "headers", {}) or {}).items()}
|
|
305
|
+
except Exception: # noqa: BLE001 第三方对象的 headers 形状不定
|
|
306
|
+
headers = {}
|
|
307
|
+
return _as_int(status), headers
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _as_int(value: Any, default: int = 0) -> int:
|
|
311
|
+
"""把可能不是数字的值收成 int(取不到 HTTP 状态不该让事件派发抛错)。"""
|
|
312
|
+
try:
|
|
313
|
+
return int(value)
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
return default
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class LiteLLMClient:
|
|
319
|
+
"""基于 litellm 的实现。spec 来自 resolve_model()(经 resolve_key 取 key)。"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, spec: ResolvedModel, auth_store: AuthStore | None = None,
|
|
322
|
+
thinking_level: str = "off", retry: dict | None = None,
|
|
323
|
+
thinking_budgets: dict[str, int] | None = None,
|
|
324
|
+
bus: Any = None, extension_ctx: Any = None):
|
|
325
|
+
self.spec = spec
|
|
326
|
+
#: 扩展事件总线 + ctx 工厂:provider 三件套事件(`before_provider_request` /
|
|
327
|
+
#: `before_provider_headers` / `after_provider_response`)靠它们派发。
|
|
328
|
+
#: 没给就是**完全不派发**(测试里的假客户端不需要)。
|
|
329
|
+
self._bus = bus
|
|
330
|
+
self._extension_ctx = extension_ctx
|
|
331
|
+
# 请求级超时/重试:归 provider SDK,不归 agent loop(见 provider_retry_params)
|
|
332
|
+
self._provider_retry = provider_retry_params(retry)
|
|
333
|
+
self._resolved = resolve_key(spec.provider, spec.api_key_ref, auth_store or AuthStore())
|
|
334
|
+
self.model_name = litellm_model_name(spec)
|
|
335
|
+
self.thinking_level = normalize_thinking_level(thinking_level)
|
|
336
|
+
#: `settings.thinkingBudgets`(按级别的 token 预算)—— 只对 Anthropic 形态生效
|
|
337
|
+
self._thinking_budgets = dict(thinking_budgets or {})
|
|
338
|
+
"""思考级别(可在运行期改:TUI 的 shift+tab / `/thinking`)。"""
|
|
339
|
+
# provider 明确拒过 reasoning_effort 后粘住这个事实,后续不再带(与 _no_usage_opt 同模式)
|
|
340
|
+
self._no_reasoning_effort = False
|
|
341
|
+
self.reasoning_dropped = False
|
|
342
|
+
"""曾因 provider 不接受而丢掉思考参数吗(供 UI 提示一次)。"""
|
|
343
|
+
# 该 provider 是否已拒绝过 stream_options(见 astream 的容错 1)
|
|
344
|
+
self._no_usage_opt = False
|
|
345
|
+
# 流式是否已确认打不开(容错 2):钉住后不再每轮重试一次注定失败的请求
|
|
346
|
+
self._stream_broken = False
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def ready(self) -> bool:
|
|
350
|
+
return self._resolved.ok
|
|
351
|
+
|
|
352
|
+
# ── provider 事件的三个接线点 ──
|
|
353
|
+
def _ctx(self) -> Any:
|
|
354
|
+
return self._extension_ctx(None) if callable(self._extension_ctx) else None
|
|
355
|
+
|
|
356
|
+
def _wants(self, event: str) -> bool:
|
|
357
|
+
return self._bus is not None and self._ctx() is not None and self._bus.has(event)
|
|
358
|
+
|
|
359
|
+
async def _prepare_request(self, kwargs: dict) -> dict:
|
|
360
|
+
"""`before_provider_request`:可**替换整个 payload**(pi 同义)。"""
|
|
361
|
+
if not self._wants("before_provider_request"):
|
|
362
|
+
return kwargs
|
|
363
|
+
result = await self._bus.emit("before_provider_request",
|
|
364
|
+
{"payload": kwargs}, ctx=self._ctx())
|
|
365
|
+
replaced = result.payload.get("payload")
|
|
366
|
+
return replaced if isinstance(replaced, dict) else kwargs
|
|
367
|
+
|
|
368
|
+
async def _prepare_headers(self, kwargs: dict) -> dict:
|
|
369
|
+
"""`before_provider_headers`:handler **原地改** `headers`(值是 None 则删掉)。"""
|
|
370
|
+
headers = dict(kwargs.get("extra_headers") or {})
|
|
371
|
+
if self._wants("before_provider_headers"):
|
|
372
|
+
result = await self._bus.emit("before_provider_headers",
|
|
373
|
+
{"headers": headers}, ctx=self._ctx())
|
|
374
|
+
patched = result.payload.get("headers")
|
|
375
|
+
if isinstance(patched, dict):
|
|
376
|
+
headers = {k: v for k, v in patched.items() if v is not None}
|
|
377
|
+
if headers:
|
|
378
|
+
kwargs["extra_headers"] = headers
|
|
379
|
+
return kwargs
|
|
380
|
+
|
|
381
|
+
async def _after_response(self, resp: Any) -> None:
|
|
382
|
+
"""`after_provider_response`:拿到响应但还没消费流时通知。"""
|
|
383
|
+
if not self._wants("after_provider_response"):
|
|
384
|
+
return
|
|
385
|
+
status, headers = _response_status_headers(resp)
|
|
386
|
+
await self._bus.emit("after_provider_response",
|
|
387
|
+
{"status": status, "headers": headers}, ctx=self._ctx())
|
|
388
|
+
|
|
389
|
+
def _base_kwargs(self, messages: list[ChatMessage], tools: list[dict] | None,
|
|
390
|
+
temperature: float | None) -> dict:
|
|
391
|
+
"""chat 与 astream 共用的请求参数(避免两处漂移)。"""
|
|
392
|
+
kwargs: dict = {
|
|
393
|
+
"model": self.model_name,
|
|
394
|
+
"messages": [m.to_dict() for m in messages],
|
|
395
|
+
}
|
|
396
|
+
if tools:
|
|
397
|
+
kwargs["tools"] = tools
|
|
398
|
+
if self._resolved.key:
|
|
399
|
+
kwargs["api_key"] = self._resolved.key
|
|
400
|
+
if self.spec.base_url:
|
|
401
|
+
kwargs["api_base"] = self.spec.base_url
|
|
402
|
+
if self.spec.max_tokens:
|
|
403
|
+
kwargs["max_tokens"] = self.spec.max_tokens
|
|
404
|
+
if temperature is not None:
|
|
405
|
+
kwargs["temperature"] = temperature
|
|
406
|
+
kwargs.update(self._provider_retry) # timeout / num_retries(若配了)
|
|
407
|
+
kwargs.update(self._reasoning_params())
|
|
408
|
+
return kwargs
|
|
409
|
+
|
|
410
|
+
def _reasoning_params(self) -> dict:
|
|
411
|
+
"""思考参数:只有模型声明 reasoning、级别非 off、且 provider 没拒过时才带。
|
|
412
|
+
|
|
413
|
+
**Anthropic 形态**额外带 `thinking: {type, budget_tokens}`(pi 的 `thinkingBudgets`)。
|
|
414
|
+
pi 对它分三路:Anthropic / Google / Bedrock **原生**用,OpenAI 兼容形态**只在**模型配了
|
|
415
|
+
`compat.thinkingTokenBudgetField`(指定"预算写进哪个请求字段")时才用。qi 只做**原生那一路**
|
|
416
|
+
(litellm 在 Anthropic 形态上有对应参数);OpenAI 兼容那半不做 —— qi 没有 compat 层,
|
|
417
|
+
而加那层是独立的一件事(见 design/pi-alignment.md)。
|
|
418
|
+
"""
|
|
419
|
+
effort = _EFFORT_MAP.get(self.thinking_level)
|
|
420
|
+
if not (effort and self.spec.reasoning and not self._no_reasoning_effort):
|
|
421
|
+
return {}
|
|
422
|
+
params: dict = {"reasoning_effort": effort}
|
|
423
|
+
budget = self._thinking_budget()
|
|
424
|
+
if budget is not None:
|
|
425
|
+
params["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
|
426
|
+
return params
|
|
427
|
+
|
|
428
|
+
def _thinking_budget(self) -> int | None:
|
|
429
|
+
"""`settings.thinkingBudgets[当前级别]` → 要带的预算;不带则 None。
|
|
430
|
+
|
|
431
|
+
三条规矩:
|
|
432
|
+
|
|
433
|
+
* **只对 Anthropic 形态**(其余形态 litellm 没有统一参数);
|
|
434
|
+
* **没配就不带** —— 不改变默认行为(pi 另有内置默认表,qi 刻不抄:那会让每个
|
|
435
|
+
Anthropic 请求都凭空带上预算);
|
|
436
|
+
* **钳制:至少留 1024 token 给答案** —— pi 的原话是 "clamped so at least 1024 tokens
|
|
437
|
+
remain for the answer"。
|
|
438
|
+
"""
|
|
439
|
+
if self.spec.api != "anthropic-messages":
|
|
440
|
+
return None
|
|
441
|
+
raw = self._thinking_budgets.get(self.thinking_level)
|
|
442
|
+
if not isinstance(raw, int) or raw <= 0:
|
|
443
|
+
return None
|
|
444
|
+
if self.spec.max_tokens:
|
|
445
|
+
return max(1024, min(raw, self.spec.max_tokens - 1024))
|
|
446
|
+
return raw
|
|
447
|
+
|
|
448
|
+
def _consider_reasoning_rejection(self, exc: BaseException) -> bool:
|
|
449
|
+
"""看这次失败是不是「provider 不接受 reasoning_effort」;是则降级并返回 True。
|
|
450
|
+
|
|
451
|
+
真实场景:自建 LiteLLM 代理默认 `drop_params` 会把未知参数丢掉并**直接报错**。
|
|
452
|
+
用户只是想调思考级别,不应该因此整轮失败——所以丢掉参数重试一次并记下来。
|
|
453
|
+
"""
|
|
454
|
+
if self._no_reasoning_effort or "reasoning_effort" not in str(exc):
|
|
455
|
+
return False
|
|
456
|
+
self._no_reasoning_effort = True
|
|
457
|
+
self.reasoning_dropped = True
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
async def chat(self, messages: list[ChatMessage], tools: list[dict] | None = None,
|
|
461
|
+
temperature: float | None = None) -> ChatResponse:
|
|
462
|
+
import litellm
|
|
463
|
+
|
|
464
|
+
kwargs = await self._prepare_headers(
|
|
465
|
+
await self._prepare_request(self._base_kwargs(messages, tools, temperature)))
|
|
466
|
+
try:
|
|
467
|
+
resp = await litellm.acompletion(**kwargs)
|
|
468
|
+
except Exception as exc:
|
|
469
|
+
if not self._consider_reasoning_rejection(exc):
|
|
470
|
+
raise
|
|
471
|
+
# 去掉 reasoning_effort 重试一次(用户只想调级别,不该因此整轮失败)
|
|
472
|
+
kwargs = await self._prepare_headers(
|
|
473
|
+
await self._prepare_request(self._base_kwargs(messages, tools, temperature)))
|
|
474
|
+
resp = await litellm.acompletion(**kwargs)
|
|
475
|
+
await self._after_response(resp)
|
|
476
|
+
# litellm 的返回类型是 "流式包装器 | 补全对象" 的联合,
|
|
477
|
+
# 这里只走非流式分支,按实际形状收窄。
|
|
478
|
+
msg = cast(Any, resp).choices[0].message
|
|
479
|
+
text = msg.content or ""
|
|
480
|
+
if isinstance(text, list): # 多模态块:取文本
|
|
481
|
+
text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
482
|
+
tool_calls = []
|
|
483
|
+
for tc in msg.tool_calls or []:
|
|
484
|
+
fn = getattr(tc, "function", None) # 自定义工具调用无 function,跳过
|
|
485
|
+
if fn is None:
|
|
486
|
+
continue
|
|
487
|
+
try:
|
|
488
|
+
args = json.loads(fn.arguments or "{}")
|
|
489
|
+
except json.JSONDecodeError:
|
|
490
|
+
args = {}
|
|
491
|
+
tool_calls.append(ToolCallOut(id=tc.id or f"call_{len(tool_calls)}",
|
|
492
|
+
name=fn.name, args=args))
|
|
493
|
+
return ChatResponse(text=text or "", tool_calls=tool_calls,
|
|
494
|
+
usage=usage_to_dict(getattr(resp, "usage", None)),
|
|
495
|
+
reasoning=reasoning_text_of(msg))
|
|
496
|
+
|
|
497
|
+
async def _open_stream(self, messages: list[ChatMessage], tools: list[dict] | None,
|
|
498
|
+
temperature: float | None, with_usage: bool) -> AsyncIterator[Any]:
|
|
499
|
+
import litellm
|
|
500
|
+
|
|
501
|
+
kwargs = await self._prepare_headers(
|
|
502
|
+
await self._prepare_request(self._base_kwargs(messages, tools, temperature)))
|
|
503
|
+
kwargs["stream"] = True
|
|
504
|
+
if with_usage:
|
|
505
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
506
|
+
# litellm 的返回类型是 "流式包装器 | 补全对象" 的联合:这里只走流式分支,
|
|
507
|
+
# 按实际形状收窄(与 chat() 的处理对称)。
|
|
508
|
+
stream = cast(AsyncIterator[Any], await litellm.acompletion(**kwargs))
|
|
509
|
+
await self._after_response(stream)
|
|
510
|
+
return stream
|
|
511
|
+
|
|
512
|
+
async def _open_stream_with_fallbacks(self, messages: list[ChatMessage],
|
|
513
|
+
tools: list[dict] | None,
|
|
514
|
+
temperature: float | None) -> AsyncIterator[Any] | None:
|
|
515
|
+
"""开流,依次容忍两种“参数不被接受”:reasoning_effort → stream_options。
|
|
516
|
+
|
|
517
|
+
顺序上先处理 reasoning(它是我们主动加的可选参数),再处理 usage 选项;
|
|
518
|
+
两次都失败就返回 None,交给上层退回非流式。
|
|
519
|
+
"""
|
|
520
|
+
for _ in range(3):
|
|
521
|
+
with_usage = not self._no_usage_opt
|
|
522
|
+
try:
|
|
523
|
+
return await self._open_stream(messages, tools, temperature, with_usage)
|
|
524
|
+
except Exception as exc: # noqa: BLE001 打开流失败:逐项降级
|
|
525
|
+
if self._consider_reasoning_rejection(exc):
|
|
526
|
+
continue
|
|
527
|
+
if with_usage:
|
|
528
|
+
self._no_usage_opt = True # 某些 provider 不认 stream_options
|
|
529
|
+
continue
|
|
530
|
+
return None
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
async def astream(self, messages: list[ChatMessage], tools: list[dict] | None = None,
|
|
534
|
+
temperature: float | None = None) -> AsyncIterator[LLMDelta]:
|
|
535
|
+
"""litellm 流式:逐块 yield 文本,最后 yield 合并好的 tool_calls 与 usage。
|
|
536
|
+
|
|
537
|
+
两处容错(均为**粘性**的,不会每轮重试):
|
|
538
|
+
1. `stream_options.include_usage` 不被某些 provider 接受 → 去掉该参数重试一次。
|
|
539
|
+
不加这个,流式下拿不到 usage,成本/token 显示会长期为空。
|
|
540
|
+
2. 流式本身打不开(网关/代理不支持)→ 永久退回非流式,整段文本作为单块发出。
|
|
541
|
+
消费者形状不变,只是不再逐字。
|
|
542
|
+
|
|
543
|
+
限制(写明白而不是假装支持):若流**已吐出部分文本后**才失败,不重试、直接抛出——
|
|
544
|
+
重试会导致文本重复。这种情况需上层处理(Web 层应转成 error 帧)。
|
|
545
|
+
"""
|
|
546
|
+
if self._stream_broken:
|
|
547
|
+
async for delta in chat_as_stream(self, messages, tools, temperature):
|
|
548
|
+
yield delta
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
stream: AsyncIterator[Any] | None = await self._open_stream_with_fallbacks(
|
|
552
|
+
messages, tools, temperature)
|
|
553
|
+
if stream is None:
|
|
554
|
+
self._stream_broken = True
|
|
555
|
+
async for delta in chat_as_stream(self, messages, tools, temperature):
|
|
556
|
+
yield delta
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
merged: dict[int, dict] = {}
|
|
560
|
+
usage: dict = {}
|
|
561
|
+
content_seen = False # 文本或思考已吐出:此时失败不能重试(会重复输出)
|
|
562
|
+
try:
|
|
563
|
+
async for chunk in stream:
|
|
564
|
+
got = usage_to_dict(getattr(chunk, "usage", None))
|
|
565
|
+
if got:
|
|
566
|
+
usage = got # 末块带 usage(已请求 include_usage)
|
|
567
|
+
choices = getattr(chunk, "choices", None) or []
|
|
568
|
+
if not choices:
|
|
569
|
+
continue
|
|
570
|
+
delta_obj = getattr(choices[0], "delta", None)
|
|
571
|
+
if delta_obj is None:
|
|
572
|
+
continue
|
|
573
|
+
text = getattr(delta_obj, "content", "") or ""
|
|
574
|
+
if isinstance(text, list): # 多模态块:取文本
|
|
575
|
+
text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
576
|
+
reasoning = reasoning_text_of(delta_obj)
|
|
577
|
+
if reasoning:
|
|
578
|
+
content_seen = True
|
|
579
|
+
yield LLMDelta(reasoning=reasoning)
|
|
580
|
+
if text:
|
|
581
|
+
content_seen = True
|
|
582
|
+
yield LLMDelta(text=text)
|
|
583
|
+
for tc in getattr(delta_obj, "tool_calls", None) or []:
|
|
584
|
+
merge_tool_call_delta(merged, tc)
|
|
585
|
+
except Exception:
|
|
586
|
+
# 首片就失败 → 可安全降级;已吐过字/思考 → 不能重试(会重复输出)
|
|
587
|
+
if content_seen:
|
|
588
|
+
raise
|
|
589
|
+
self._stream_broken = True
|
|
590
|
+
async for delta in chat_as_stream(self, messages, tools, temperature):
|
|
591
|
+
yield delta
|
|
592
|
+
return
|
|
593
|
+
yield LLMDelta(finished=True, tool_calls=merged_tool_calls(merged), usage=usage)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def chat_message_from_dict(d: dict) -> ChatMessage:
|
|
597
|
+
"""从会话 JSONL 反序列化(与 ChatMessage.to_dict 对应)。"""
|
|
598
|
+
msg = ChatMessage(role=str(d.get("role", "user")), content=str(d.get("content", "")))
|
|
599
|
+
if d.get("tool_call_id"):
|
|
600
|
+
msg.tool_call_id = d["tool_call_id"]
|
|
601
|
+
for tc in d.get("tool_calls") or []:
|
|
602
|
+
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
|
603
|
+
try:
|
|
604
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
605
|
+
except json.JSONDecodeError:
|
|
606
|
+
args = {}
|
|
607
|
+
msg.tool_calls.append(ToolCallOut(
|
|
608
|
+
id=tc.get("id", "call"), name=fn.get("name", "?"), args=args))
|
|
609
|
+
return msg
|