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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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