foxclaw 0.1.2 → 0.1.3

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 (344) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/CHANGELOG.md +57 -0
  6. package/python/CHANGELOG_CN.md +57 -0
  7. package/python/README.md +11 -2
  8. package/python/README_CN.md +32 -20
  9. package/python/bench/bm25_audit.py +1 -14
  10. package/python/bench/bm25_regression.py +0 -1
  11. package/python/bench/bm25_stress.py +0 -4
  12. package/python/bench/cd_audit.py +0 -23
  13. package/python/bench/chaos.py +6 -8
  14. package/python/bench/e2e_real_models.py +0 -1
  15. package/python/bench/llm_retry_test.py +2 -9
  16. package/python/bench/load_test.py +5 -8
  17. package/python/bench/vector_ivf_upgrade.py +1 -7
  18. package/python/docs/RELEASE.md +4 -3
  19. package/python/docs/RUN_CN.md +2 -2
  20. package/python/evals/governance/cases.py +0 -37
  21. package/python/evals/governance/run_evals.py +5 -37
  22. package/python/examples/governance_middleware_demo.py +1 -6
  23. package/python/examples/langgraph_governance_demo.py +7 -11
  24. package/python/foxclaw/__init__.py +34 -152
  25. package/python/foxclaw/agents/base.py +65 -136
  26. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  27. package/python/foxclaw/agents/impl/finance.py +4 -21
  28. package/python/foxclaw/agents/impl/office.py +7 -24
  29. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  30. package/python/foxclaw/agents/mixins/_context.py +0 -23
  31. package/python/foxclaw/agents/mixins/augment.py +10 -16
  32. package/python/foxclaw/agents/mixins/llm.py +7 -6
  33. package/python/foxclaw/agents/mixins/memory.py +7 -1
  34. package/python/foxclaw/agents/mixins/preference.py +58 -8
  35. package/python/foxclaw/agents/mixins/prompt.py +0 -6
  36. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  37. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  38. package/python/foxclaw/api/__init__.py +18 -77
  39. package/python/foxclaw/api/_state.py +43 -0
  40. package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
  41. package/python/foxclaw/api/middleware.py +5 -28
  42. package/python/foxclaw/api/routes/ahs.py +60 -73
  43. package/python/foxclaw/api/routes/attachments.py +5 -6
  44. package/python/foxclaw/api/routes/browser.py +7 -11
  45. package/python/foxclaw/api/routes/domain.py +18 -26
  46. package/python/foxclaw/api/routes/knowledge.py +3 -21
  47. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  48. package/python/foxclaw/api/routes/memory.py +28 -12
  49. package/python/foxclaw/api/routes/orchestration.py +3 -5
  50. package/python/foxclaw/api/routes/recovery.py +4 -3
  51. package/python/foxclaw/api/routes/review.py +2 -11
  52. package/python/foxclaw/api/routes/search.py +3 -7
  53. package/python/foxclaw/api/routes/system.py +33 -49
  54. package/python/foxclaw/api/routes/task.py +31 -49
  55. package/python/foxclaw/api/routes/ws.py +2 -6
  56. package/python/foxclaw/api/spine.py +1 -4
  57. package/python/foxclaw/browser/action/action_executor.py +1 -5
  58. package/python/foxclaw/browser/action/action_types.py +0 -1
  59. package/python/foxclaw/browser/action/url_policy.py +1 -5
  60. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  61. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  62. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  63. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  64. package/python/foxclaw/browser/service.py +7 -11
  65. package/python/foxclaw/capability/embedding_service.py +4 -6
  66. package/python/foxclaw/channels/__init__.py +9 -11
  67. package/python/foxclaw/channels/discord.py +4 -13
  68. package/python/foxclaw/channels/dispatcher.py +2 -27
  69. package/python/foxclaw/channels/feishu.py +10 -20
  70. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  71. package/python/foxclaw/channels/signing.py +9 -9
  72. package/python/foxclaw/channels/telegram.py +3 -4
  73. package/python/foxclaw/channels/weixin.py +7 -17
  74. package/python/foxclaw/cli.py +42 -13
  75. package/python/foxclaw/cognitive/reasoner.py +9 -10
  76. package/python/foxclaw/cognitive/state.py +18 -24
  77. package/python/foxclaw/core/audit_chain.py +1 -3
  78. package/python/foxclaw/core/audit_sink.py +3 -3
  79. package/python/foxclaw/core/compaction.py +8 -26
  80. package/python/foxclaw/core/config.py +2 -2
  81. package/python/foxclaw/core/confirm_nonce.py +6 -12
  82. package/python/foxclaw/core/envfile.py +2 -2
  83. package/python/foxclaw/core/failure_policy.py +17 -26
  84. package/python/foxclaw/core/gateway.py +6 -10
  85. package/python/foxclaw/core/governance_audit.py +3 -7
  86. package/python/foxclaw/core/idempotency.py +15 -22
  87. package/python/foxclaw/core/injection_guard.py +10 -29
  88. package/python/foxclaw/core/memory_adapter.py +30 -57
  89. package/python/foxclaw/core/metrics.py +4 -5
  90. package/python/foxclaw/core/model_router.py +36 -148
  91. package/python/foxclaw/core/paths.py +1 -1
  92. package/python/foxclaw/core/recovery.py +21 -34
  93. package/python/foxclaw/core/scheduler.py +23 -26
  94. package/python/foxclaw/core/secrets.py +0 -3
  95. package/python/foxclaw/core/sessions.py +4 -6
  96. package/python/foxclaw/core/settings.py +16 -32
  97. package/python/foxclaw/core/shared_write.py +11 -23
  98. package/python/foxclaw/core/steering_queue.py +12 -11
  99. package/python/foxclaw/core/system_bus.py +1 -1
  100. package/python/foxclaw/core/thought_chain.py +30 -32
  101. package/python/foxclaw/core/token_meter.py +9 -13
  102. package/python/foxclaw/core/tools.py +4 -12
  103. package/python/foxclaw/core/tracing.py +25 -40
  104. package/python/foxclaw/core/truth_pipeline.py +5 -9
  105. package/python/foxclaw/core/update_check.py +149 -0
  106. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  107. package/python/foxclaw/core/verify.py +6 -14
  108. package/python/foxclaw/core/web_search.py +4 -28
  109. package/python/foxclaw/finance/evidence.py +14 -18
  110. package/python/foxclaw/finance/fx.py +1 -3
  111. package/python/foxclaw/finance/graph.py +9 -18
  112. package/python/foxclaw/finance/models.py +20 -24
  113. package/python/foxclaw/finance/nlparse.py +12 -72
  114. package/python/foxclaw/finance/policy.py +8 -36
  115. package/python/foxclaw/finance/reasoner.py +3 -11
  116. package/python/foxclaw/finance/reasoning.py +13 -34
  117. package/python/foxclaw/finance/reconcile.py +5 -10
  118. package/python/foxclaw/finance/runtime.py +19 -46
  119. package/python/foxclaw/governance/middleware.py +13 -21
  120. package/python/foxclaw/harness/_base.py +2 -8
  121. package/python/foxclaw/harness/_policy.py +19 -38
  122. package/python/foxclaw/harness/drift.py +1 -13
  123. package/python/foxclaw/harness/event_bus.py +2 -5
  124. package/python/foxclaw/harness/harness.py +9 -97
  125. package/python/foxclaw/harness/loop_detection.py +11 -18
  126. package/python/foxclaw/itinerary/amap_client.py +2 -5
  127. package/python/foxclaw/itinerary/models.py +7 -7
  128. package/python/foxclaw/itinerary/reasoner.py +1 -6
  129. package/python/foxclaw/itinerary/reasoning.py +15 -21
  130. package/python/foxclaw/itinerary/runtime.py +8 -12
  131. package/python/foxclaw/kernel/capabilities.py +5 -6
  132. package/python/foxclaw/kernel/policy_gate.py +4 -10
  133. package/python/foxclaw/kernel/process_manager.py +17 -36
  134. package/python/foxclaw/kernel/scheduler.py +3 -9
  135. package/python/foxclaw/kernel/sentinel.py +13 -23
  136. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  137. package/python/foxclaw/kernel/spine.py +9 -9
  138. package/python/foxclaw/knowledge/__init__.py +4 -20
  139. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  140. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  141. package/python/foxclaw/learning/models.py +13 -13
  142. package/python/foxclaw/learning/reasoner.py +1 -4
  143. package/python/foxclaw/learning/runtime.py +19 -31
  144. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  145. package/python/foxclaw/mcp_server.py +2 -3
  146. package/python/foxclaw/orchestration/__init__.py +16 -33
  147. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  148. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  149. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  150. package/python/foxclaw/planning/llm_planner.py +3 -3
  151. package/python/foxclaw/planning/planner.py +6 -11
  152. package/python/foxclaw/search/agent_port.py +7 -11
  153. package/python/foxclaw/search/evidence.py +0 -4
  154. package/python/foxclaw/search/internal_search.py +3 -5
  155. package/python/foxclaw/search/models.py +11 -13
  156. package/python/foxclaw/search/planner.py +3 -3
  157. package/python/foxclaw/search/retriever.py +1 -1
  158. package/python/foxclaw/search/runtime.py +1 -8
  159. package/python/foxclaw/skills/__init__.py +46 -100
  160. package/python/foxclaw/skills/archive.py +111 -0
  161. package/python/foxclaw/skills/fetch.py +226 -0
  162. package/python/foxclaw/skills/hooks_spec.py +21 -25
  163. package/python/foxclaw/skills/install_cmd.py +216 -0
  164. package/python/foxclaw/skills/lifecycle.py +16 -42
  165. package/python/foxclaw/skills/markdown_skill.py +10 -9
  166. package/python/foxclaw/skills/mcp_client.py +5 -11
  167. package/python/foxclaw/skills/mcp_store.py +0 -3
  168. package/python/foxclaw/skills/permission.py +13 -48
  169. package/python/foxclaw/skills/pipeline.py +21 -53
  170. package/python/foxclaw/skills/policy.py +4 -10
  171. package/python/foxclaw/skills/review.py +3 -3
  172. package/python/frontend/deck/auth.js +0 -7
  173. package/python/frontend/deck/chat_model_picker.js +0 -54
  174. package/python/frontend/deck/domain.js +0 -20
  175. package/python/frontend/deck/main.js +0 -144
  176. package/python/frontend/deck/markdown.js +0 -4
  177. package/python/frontend/deck/settings.js +1 -18
  178. package/python/frontend/deck/state.js +0 -1
  179. package/python/frontend/deck/util.js +0 -2
  180. package/python/frontend/deck/visuals.js +0 -22
  181. package/python/frontend/planet/planet.js +0 -201
  182. package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
  183. package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
  184. package/python/frontend/v3/index.html +3 -3
  185. package/python/frontend/widget_runtime.js +0 -43
  186. package/python/frontend/widgets.js +0 -57
  187. package/python/frontend_src/index.html +1 -1
  188. package/python/frontend_src/package-lock.json +2 -2
  189. package/python/frontend_src/package.json +1 -1
  190. package/python/frontend_src/src/App.vue +3 -7
  191. package/python/frontend_src/src/api.js +0 -7
  192. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  193. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  194. package/python/frontend_src/src/components/Inspector.vue +0 -6
  195. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  196. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  197. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  198. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  199. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  200. package/python/frontend_src/src/components/Stage.vue +71 -25
  201. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  202. package/python/frontend_src/src/components/TopBar.vue +38 -10
  203. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  204. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  205. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  206. package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
  207. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  208. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  209. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  210. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  211. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  212. package/python/frontend_src/src/lib/fmt.js +0 -4
  213. package/python/frontend_src/src/lib/i18n.js +53 -0
  214. package/python/frontend_src/src/lib/locales/en.js +173 -0
  215. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  216. package/python/frontend_src/src/lib/md.js +0 -14
  217. package/python/frontend_src/src/main.js +2 -0
  218. package/python/frontend_src/src/stores/app.js +17 -32
  219. package/python/frontend_src/src/stores/chat.js +0 -34
  220. package/python/frontend_src/src/stores/model.js +0 -8
  221. package/python/frontend_src/src/stores/panels.js +0 -41
  222. package/python/frontend_src/src/stores/profile.js +31 -0
  223. package/python/frontend_src/src/stores/system.js +25 -0
  224. package/python/frontend_src/vite.config.js +0 -7
  225. package/python/pyproject.toml +6 -3
  226. package/python/requirements.txt +311 -0
  227. package/python/tests/conftest.py +0 -22
  228. package/python/tests/run_all.py +3 -66
  229. package/python/tests/test_agent_lifecycle.py +4 -38
  230. package/python/tests/test_ahs_p2.py +4 -25
  231. package/python/tests/test_anthropic_tools.py +1 -15
  232. package/python/tests/test_api.py +19 -9
  233. package/python/tests/test_api_auth.py +1 -5
  234. package/python/tests/test_attachments.py +9 -25
  235. package/python/tests/test_audit_chain.py +2 -7
  236. package/python/tests/test_audit_sink.py +0 -6
  237. package/python/tests/test_browser.py +2 -6
  238. package/python/tests/test_browser_request_guard.py +0 -4
  239. package/python/tests/test_capabilities.py +0 -6
  240. package/python/tests/test_channel_signing.py +2 -9
  241. package/python/tests/test_channels.py +1 -11
  242. package/python/tests/test_config_layering.py +0 -4
  243. package/python/tests/test_confirm_nonce.py +1 -12
  244. package/python/tests/test_context_compaction_wiring.py +1 -7
  245. package/python/tests/test_dispatch.py +0 -6
  246. package/python/tests/test_domain_evals.py +11 -80
  247. package/python/tests/test_domain_import.py +0 -6
  248. package/python/tests/test_domain_reasoners.py +0 -14
  249. package/python/tests/test_e2e.py +0 -17
  250. package/python/tests/test_emb_cache.py +7 -17
  251. package/python/tests/test_embedding_singleton.py +1 -3
  252. package/python/tests/test_evals_smoke.py +0 -24
  253. package/python/tests/test_event_spine.py +10 -18
  254. package/python/tests/test_evidence_semantic.py +1 -14
  255. package/python/tests/test_evidence_sse.py +3 -6
  256. package/python/tests/test_file_ingest.py +0 -11
  257. package/python/tests/test_finance.py +0 -9
  258. package/python/tests/test_finance_ledger.py +5 -12
  259. package/python/tests/test_finance_persist.py +8 -12
  260. package/python/tests/test_gateway.py +3 -22
  261. package/python/tests/test_governance.py +5 -52
  262. package/python/tests/test_governance_audit.py +4 -39
  263. package/python/tests/test_governance_middleware.py +1 -7
  264. package/python/tests/test_hkc.py +6 -24
  265. package/python/tests/test_hkc_index_build_manager.py +0 -33
  266. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  267. package/python/tests/test_hkc_search_event_sync.py +3 -54
  268. package/python/tests/test_import_path_guard.py +0 -6
  269. package/python/tests/test_injection_guard.py +18 -36
  270. package/python/tests/test_integration.py +5 -29
  271. package/python/tests/test_itinerary.py +0 -3
  272. package/python/tests/test_kee_semantic.py +0 -5
  273. package/python/tests/test_kernel.py +1 -11
  274. package/python/tests/test_learning.py +3 -12
  275. package/python/tests/test_llm_admin.py +4 -19
  276. package/python/tests/test_llm_planner.py +1 -23
  277. package/python/tests/test_markdown_skills.py +3 -14
  278. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  279. package/python/tests/test_mcp_connectors.py +3 -15
  280. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  281. package/python/tests/test_mcp_no_memory.py +3 -3
  282. package/python/tests/test_mcp_policy_gate.py +0 -1
  283. package/python/tests/test_mcp_server.py +6 -9
  284. package/python/tests/test_memory_recall.py +1 -4
  285. package/python/tests/test_metrics.py +0 -8
  286. package/python/tests/test_multi_chain.py +1 -13
  287. package/python/tests/test_multimodal.py +5 -18
  288. package/python/tests/test_negotiation.py +3 -13
  289. package/python/tests/test_orchestration.py +1 -11
  290. package/python/tests/test_orchestration_decoupled.py +0 -6
  291. package/python/tests/test_p3_p5_hardening.py +3 -17
  292. package/python/tests/test_parallel_dispatch.py +4 -12
  293. package/python/tests/test_pipeline.py +17 -58
  294. package/python/tests/test_plan_visible.py +4 -4
  295. package/python/tests/test_planner.py +0 -7
  296. package/python/tests/test_policy_gate.py +2 -3
  297. package/python/tests/test_profile.py +67 -0
  298. package/python/tests/test_readme_claims.py +163 -0
  299. package/python/tests/test_reasoner.py +2 -17
  300. package/python/tests/test_release_check.py +2 -13
  301. package/python/tests/test_resume_checkpoint.py +7 -15
  302. package/python/tests/test_routing.py +2 -12
  303. package/python/tests/test_scheduler.py +7 -10
  304. package/python/tests/test_search.py +1 -11
  305. package/python/tests/test_secrets.py +4 -10
  306. package/python/tests/test_semantic.py +1 -10
  307. package/python/tests/test_sessions.py +3 -4
  308. package/python/tests/test_settings.py +2 -18
  309. package/python/tests/test_shadow_learning.py +3 -43
  310. package/python/tests/test_shared_write.py +0 -7
  311. package/python/tests/test_skill_taint_gate.py +1 -10
  312. package/python/tests/test_skills.py +2 -18
  313. package/python/tests/test_skills_fetch.py +282 -0
  314. package/python/tests/test_stats_and_events.py +2 -5
  315. package/python/tests/test_streaming.py +2 -7
  316. package/python/tests/test_supervisor_pipeline.py +4 -5
  317. package/python/tests/test_supervisor_triage.py +4 -10
  318. package/python/tests/test_taint.py +2 -7
  319. package/python/tests/test_task_stream.py +1 -12
  320. package/python/tests/test_thinking_sse.py +0 -1
  321. package/python/tests/test_tool_center.py +1 -17
  322. package/python/tests/test_tool_parallel.py +2 -2
  323. package/python/tests/test_tool_params.py +1 -5
  324. package/python/tests/test_tool_use.py +0 -13
  325. package/python/tests/test_toplevel_concurrency.py +3 -4
  326. package/python/tests/test_tracing.py +7 -41
  327. package/python/tests/test_truth_crystallizer.py +4 -6
  328. package/python/tests/test_truth_flow.py +2 -7
  329. package/python/tests/test_update_check.py +95 -0
  330. package/python/tests/test_verify_evidence.py +8 -21
  331. package/python/tests/test_version_sync.py +35 -6
  332. package/python/tests/test_web_trigger.py +0 -17
  333. package/python/tools/check_faiss_type.py +0 -1
  334. package/python/tools/check_log.ps1 +0 -8
  335. package/python/tools/mypy_baseline.txt +12 -4
  336. package/python/tools/mypy_ratchet.py +52 -6
  337. package/python/tools/pack.py +0 -5
  338. package/python/tools/release_check.py +7 -7
  339. package/python/tools/restart_foxclaw.ps1 +5 -8
  340. package/python/tools/shared_write_audit.py +1 -15
  341. package/python/tools/size_check.py +1 -1
  342. package/python/tools/strip_comments.py +228 -0
  343. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  344. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -20,17 +20,10 @@ import json
20
20
  import threading
21
21
  import re as _re
22
22
  from pathlib import Path
23
+ from typing import Any
23
24
  from urllib.parse import urlsplit, urlunsplit
24
25
 
25
26
 
26
- # ── BASE URL 规范化(自定义模型)─────────────────────────────────────────────
27
- # 病根:用户按厂商文档填 `https://api.deepseek.com`(无 /v1),OpenAI SDK 会拼成
28
- # `https://api.deepseek.com/chat/completions` —— 兼容网关多数只认 `/v1/chat/completions`,
29
- # 于是 404/401;/v1 又常被"看文档照抄"的人漏掉。两边都不该让用户自己猜:
30
- # · 无 scheme → 补 https://
31
- # · path 为空或 "/" → 补 /v1
32
- # · 已有 path(/v1、/compatible-mode/v1、/openai/v1…)→ 原样保留
33
- # 幂等:normalize(normalize(x)) == normalize(x)。
34
27
  def normalize_base_url(url: str) -> str:
35
28
  """把用户填的 BASE URL 收拾成 SDK 可直接用的形式(带 scheme + 至少一段 path)。
36
29
 
@@ -55,15 +48,7 @@ def normalize_base_url(url: str) -> str:
55
48
  return urlunsplit((parts.scheme.lower(), parts.netloc, path, parts.query, ""))
56
49
 
57
50
 
58
- # ── 推理块剥离:openai-compatible 协议(DeepSeek-R1 / MiniMax-Text-01 等)把内部
59
- # reasoning 嵌进 content 字段,标签是 OPEN_TAG ... CLOSE_TAG(也兼容 reasoning / reflection
60
- # 变体)。流式一个字一个字 yield 时如果不在 LLM 咽喉处清理,会渗到前端 textContent /
61
- # SSE 流量 / 落库记忆。统一在 complete() / stream() 输出端 strip 是最稳的 single source。
62
- # ────────────────────────────────────────────────────────────────────
63
- # 用字符串拼接定义正则:避免 think 标签在源码里裸出现时被 markdown / 编辑器误处理。
64
51
  _THINK_RE = _re.compile("<" + "think" + ">.*?</" + "think" + ">", _re.DOTALL)
65
- # 部分模型(DeepSeek-R1 早期 / minimax 变体)用 <reasoning> / <reflection> 标签
66
- # 一并兼容,展示给用户时统一清掉。
67
52
  _EXTRA_THINK_RE = _re.compile(r"<(reasoning|reflection|reflexion)>.*?</\1>", _re.DOTALL)
68
53
 
69
54
 
@@ -114,7 +99,6 @@ class _ThinkStripper:
114
99
  if self._in_think:
115
100
  end = text.find(self._CLOSE_TAG, i)
116
101
  if end == -1:
117
- # 没遇到结束标签,剩余暂存,等下个 chunk 续上
118
102
  self._buf = text[i:]
119
103
  return "".join(out), "".join(think)
120
104
  think.append(text[i:end])
@@ -124,10 +108,6 @@ class _ThinkStripper:
124
108
  else:
125
109
  start = text.find(self._OPEN_TAG, i)
126
110
  if start == -1:
127
- # 当前 chunk 没有 <think> 开始标签。
128
- # 但末尾可能正好切成 "<" / "<th" / "<thi" ... —— 不能直接
129
- # 整段 yield,否则下个 chunk 拼成 "<think>..." 时已经晚一步。
130
- # 策略:把最后一个 '<' 之前的安全段 yield,'<' 及其后暂存到 buf。
131
111
  last_lt = text.rfind("<", i)
132
112
  if last_lt == -1:
133
113
  out.append(text[i:])
@@ -141,7 +121,6 @@ class _ThinkStripper:
141
121
  return "".join(out), "".join(think)
142
122
 
143
123
 
144
- # 各 provider 的默认模型(可被环境变量覆盖)
145
124
  DEFAULT_MODELS = {
146
125
  "anthropic": "claude-sonnet-4-20250514",
147
126
  "openai": "gpt-4o",
@@ -150,10 +129,9 @@ DEFAULT_MODELS = {
150
129
  "qwen": "qwen-plus",
151
130
  "local": "llama3",
152
131
  "minimax": "MiniMax-Text-01",
153
- "generic": "", # 通用 openai-compatible:必须显式给 model(.env: FOXCLAW_MODEL 或 GENERIC_LLM_MODEL)
132
+ "generic": "",
154
133
  }
155
134
 
156
- # 分层模型:fast(便宜快,分诊/简单)/ smart(强,复杂/分析)。未列的 provider fast=smart=默认。
157
135
  TIER_MODELS = {
158
136
  "anthropic": {"fast": "claude-haiku-4-20250514", "smart": DEFAULT_MODELS["anthropic"]},
159
137
  "deepseek": {"fast": "deepseek-chat", "smart": "deepseek-reasoner"},
@@ -161,7 +139,6 @@ TIER_MODELS = {
161
139
  "qwen": {"fast": "qwen-turbo", "smart": "qwen-plus"},
162
140
  }
163
141
 
164
- # 任务复杂度触发 smart 档的词
165
142
  _SMART_HINTS = _re.compile(
166
143
  r"分析|规划|报告|方案|评估|深度|论证|拆解|设计|策略|analyz|plan|report|design|strateg|reason")
167
144
 
@@ -186,16 +163,15 @@ def _tier_model(provider: str, tier: str) -> str:
186
163
  return t[tier]
187
164
  return DEFAULT_MODELS.get(provider, "")
188
165
 
189
- # provider → 所需环境变量
190
166
  PROVIDER_ENV = {
191
167
  "anthropic": "ANTHROPIC_API_KEY",
192
168
  "openai": "OPENAI_API_KEY",
193
169
  "deepseek": "DEEPSEEK_API_KEY",
194
170
  "google": "GOOGLE_API_KEY",
195
171
  "qwen": "DASHSCOPE_API_KEY",
196
- "local": "LOCAL_LLM_BASE", # 本地用 base_url 而非 key
197
- "minimax": "MINIMAX_API_KEY", # MiniMax 官方 API;鉴权还需 MINIMAX_GROUP_ID
198
- "generic": "LLM_API_KEY", # 【P2·单 LLM 入口】通用 openai-compatible;鉴权见 LLM_EXTRA_HEADERS
172
+ "local": "LOCAL_LLM_BASE",
173
+ "minimax": "MINIMAX_API_KEY",
174
+ "generic": "LLM_API_KEY",
199
175
  }
200
176
 
201
177
 
@@ -206,15 +182,9 @@ def _chunk_text(text, size: int = 8):
206
182
  yield text[i:i + size]
207
183
 
208
184
 
209
- # ── 重试退避(M6)────────────────────────────────────────────────────
210
- # 病根:此前 fallback 链只有「换 provider」,无退避、无抖动 —— 循环内无任何 sleep,
211
- # 冷却固定 20s。多实例同时宕机时会同步解冻(惊群)并紧密重试,加剧上游压力。
212
- # 下面用指数退避 + **全抖动**(full jitter,sleep=random(0,min(cap,base*2^n))):
213
- # 上限保证不会无限等待,抖动保证多实例不会同步重试。
214
185
  import random as _rnd # noqa: E402 (供下方 _backoff_delay 与冷却抖动使用)
215
186
  from . import settings
216
187
 
217
- # 冷却基准秒(乘抖动系数后使用)。可经 FOXCLAW_LLM_COOLDOWN 覆盖,线上调参不必改代码。
218
188
  _COOLDOWN_BASE = float(settings.get_float("FOXCLAW_LLM_COOLDOWN"))
219
189
 
220
190
 
@@ -229,42 +199,30 @@ def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 8.0) -> float:
229
199
  class ModelRouter:
230
200
  """统一模型路由。Agent 只认 router.complete(),不关心背后是哪家模型。"""
231
201
 
232
- def __init__(self, provider: str = None, model: str = None):
233
- # 默认 provider:按环境变量自动探测第一个可用的
202
+ def __init__(self, provider: str | None = None, model: str | None = None):
234
203
  self.provider = provider or self._auto_detect()
235
- # 【P2·单 LLM 入口】FOXCLAW_MODEL 已弃用,改读 LLM_MODEL(空值时回退默认模型)
236
204
  _env_model = os.environ.get("LLM_MODEL", "").strip()
237
- self._locked_model = model or _env_model or "" # 显式锁定 → 不分档(向后兼容)
205
+ self._locked_model: str | None = model or _env_model or ""
238
206
  self.model = self._locked_model or DEFAULT_MODELS.get(self.provider, "")
239
- self._client = None
240
- self._clients = {} # provider -> client(fallback 懒建缓存)
207
+ self._client: Any = None
208
+ self._clients: dict[str, Any] = {}
241
209
  self._client_lock = threading.Lock()
242
- self._cooldown = {} # provider -> 冷却截止 monotonic(断路器:跳过刚宕机的家)
210
+ self._cooldown: dict[str, float] = {}
243
211
  self.fallback_reason = ""
244
- self.meter = None # 可选 TokenMeter,由 Foxclaw 注入
245
- # ── 降级状态(A 类修复)────────────────────────────────────────
246
- # 区分两种「拿不到可用回复」,二者对外文案与副作用不同:
247
- # · never_configured —— 从未配置任何模型 key(离线演示/自测),stub 是合理兜底;
248
- # · call_failed —— 配了 key 但调用失败/返回空串 → **故障**,禁止伪装成功。
249
- # 后者由 _mark_degraded 标记,上游据此**禁止写入长期记忆**(故障文本会污染召回),
250
- # 并在文案中显式声明未完成(与 base.py §二「真相优先」对齐)。
212
+ self.meter: Any = None
251
213
  self.degraded: bool = False
252
214
  self.degraded_reason: str = ""
253
- # per-call 降级标记(单次请求生命周期内消费,不跨调用泄漏)
254
215
  self._call_degraded: bool = False
255
- # 前端 LLM 管理选模型(写 .current_model → mtime 缓存 lazy reload,无需重启)
256
216
  self._selection_mtime: float = 0.0
257
217
  self._init_client()
258
218
 
259
219
  @staticmethod
260
220
  def _auto_detect() -> str:
261
221
  """选择 provider:优先 LLM_PROVIDER 显式指定,否则按优先级探测已配 key 的。"""
262
- # 1. 显式指定(LLM_PROVIDER=deepseek 即可强制用 DeepSeek;兼容旧名 FOXCLAW_PROVIDER)
263
222
  forced = (os.environ.get("LLM_PROVIDER", "")
264
223
  or os.environ.get("FOXCLAW_PROVIDER", "")).strip().lower()
265
224
  if forced and forced in PROVIDER_ENV:
266
225
  return forced
267
- # 2. 自动探测:按优先级找第一个配了 key 的
268
226
  for p in ["anthropic", "openai", "deepseek", "google", "qwen", "local", "minimax", "generic"]:
269
227
  if os.environ.get(PROVIDER_ENV[p]):
270
228
  return p
@@ -278,7 +236,7 @@ class ModelRouter:
278
236
  def is_stub(self) -> bool:
279
237
  return self.provider == "stub" or self._client is None
280
238
 
281
- def _build_client(self, p: str, model: str = None):
239
+ def _build_client(self, p: str, model: str | None = None):
282
240
  """构造某 provider 的客户端(供 active 初始化 + fallback 懒建复用)。
283
241
  返回 (client_or_None, reason)。SDK 缺/无 key → (None, 原因)。"""
284
242
  try:
@@ -288,15 +246,11 @@ class ModelRouter:
288
246
  return (anthropic.Anthropic(api_key=key, timeout=30.0) if key else None,
289
247
  "" if key else "未配置 ANTHROPIC_API_KEY")
290
248
  elif p in ("openai", "deepseek", "qwen", "local", "minimax", "generic"):
291
- # ── OpenAI-compatible 家族 ───────────────────────────
292
- # 适配所有 OpenAI Chat Completion 协议兼容厂商。
293
- # 鉴权默认 Bearer(api_key);特殊厂商(双 header / 自定义鉴权)通过
294
- # default_headers 注入。generic 走完全 .env 驱动,任意厂商即插即用。
295
249
  try:
296
250
  from openai import OpenAI
297
251
  except ImportError:
298
252
  return None, "需安装 openai 库(pip install openai)"
299
- extra = {} # default_headers(双 header 鉴权用,例 MiniMax-Group-Id)
253
+ extra = {}
300
254
  if p == "openai":
301
255
  key = os.environ.get("OPENAI_API_KEY"); base = None
302
256
  elif p == "deepseek":
@@ -308,16 +262,12 @@ class ModelRouter:
308
262
  key = os.environ.get("LOCAL_LLM_KEY", "local")
309
263
  base = os.environ.get("LOCAL_LLM_BASE", "http://localhost:11434/v1")
310
264
  elif p == "minimax":
311
- # 官方域名,可用 MINIMAX_BASE_URL 自定义代理;group_id 可选(无则不传 header)
312
265
  key = os.environ.get("MINIMAX_API_KEY")
313
266
  base = os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
314
267
  group_id = os.environ.get("MINIMAX_GROUP_ID", "").strip()
315
268
  if group_id:
316
269
  extra = {"MiniMax-Group-Id": group_id}
317
- else: # generic
318
- # 【P2·单 LLM 入口】通用 openai-compatible:适配任意兼容厂商(MiniMax/GLM/Kimi/自建网关/三方代理)
319
- # 必填:LLM_API_KEY + LLM_BASE_URL
320
- # 可选:LLM_EXTRA_HEADERS(JSON object,适配双 header 鉴权)
270
+ else:
321
271
  key = os.environ.get("LLM_API_KEY") or os.environ.get("GENERIC_LLM_API_KEY")
322
272
  base = os.environ.get("LLM_BASE_URL") or os.environ.get("GENERIC_LLM_BASE_URL")
323
273
  raw = os.environ.get("LLM_EXTRA_HEADERS", "").strip() \
@@ -373,9 +323,6 @@ class ModelRouter:
373
323
  order.append(p)
374
324
  return order
375
325
 
376
- # ── 前端 LLM 管理(写 .current_model → mtime 缓存 lazy reload)────────────────
377
- # 配套文件 FOXCLAW_DATA_DIR/.current_model(由 foxclaw/api/routes/llm_admin.py 写)。
378
- # 改动走 mtime 比对,正常 chat 路径上每请求 < 1ms;切完模型不用重启 foxclaw。
379
326
 
380
327
  def _selection_path(self) -> "Path":
381
328
  return Path(os.environ.get("FOXCLAW_DATA_DIR", "./foxclaw_data")) / ".current_model"
@@ -397,13 +344,11 @@ class ModelRouter:
397
344
  match = next((m for m in models if m.get("name") == name), None)
398
345
  if not match:
399
346
  return None, "", f"未找到自定义模型: {name}"
400
- # key:OS 凭据库优先,回退 base64(旧记录)/ 更早的明文字段
401
347
  key = _secrets.load(name, match.get("api_key_obf", "") or "")
402
348
  if not key:
403
349
  key = match.get("api_key", "") or ""
404
350
  if not key:
405
351
  return None, "", "API key 为空"
406
- # 老记录可能存成不带 /v1 的形式 → 构造时统一规范化(与 llm_admin 新增/编辑同源)
407
352
  try:
408
353
  base_url = normalize_base_url(match.get("base_url", ""))
409
354
  except ValueError as e:
@@ -435,10 +380,9 @@ class ModelRouter:
435
380
  except Exception:
436
381
  mtime = 0.0
437
382
  if mtime == self._selection_mtime:
438
- return # 没变 → 零开销
383
+ return
439
384
  self._selection_mtime = mtime
440
385
  if not p.exists():
441
- # 删了 .current_model → 退回 .env 探测
442
386
  env_prov = self._auto_detect()
443
387
  if env_prov != self.provider:
444
388
  self.provider = env_prov
@@ -456,8 +400,8 @@ class ModelRouter:
456
400
  new_prov = d["provider"]
457
401
  if new_prov != self.provider:
458
402
  self.provider = new_prov
459
- self._reset_locked_for_provider() # 切 provider:FOXCLAW_MODEL 仅 generic-like 适用
460
- self._init_client() # 重建 _client
403
+ self._reset_locked_for_provider()
404
+ self._init_client()
461
405
  elif src == "custom":
462
406
  name = d.get("name", "")
463
407
  client, model, err = self._build_custom_client(name)
@@ -465,12 +409,11 @@ class ModelRouter:
465
409
  self.provider = "custom"
466
410
  with self._client_lock:
467
411
  self._clients["custom"] = client
468
- self._client = client # 同步 active
412
+ self._client = client
469
413
  self.model = model
470
414
  self._locked_model = model
471
415
  self.fallback_reason = ""
472
416
  else:
473
- # 构造失败:log + 保持旧 provider(不静默吞)
474
417
  import sys as _sys
475
418
  print(f"[model_router] 自定义模型构造失败: {err}", file=_sys.stderr)
476
419
 
@@ -482,10 +425,8 @@ class ModelRouter:
482
425
  ollama 调 → 404)。"""
483
426
  _env = os.environ.get("FOXCLAW_MODEL", "").strip()
484
427
  if self.provider in ("generic", "minimax", "custom"):
485
- # 这些 provider 没固定默认,FOXCLAW_MODEL 适用
486
428
  self._locked_model = _env or None
487
429
  else:
488
- # 其他 provider 用自己默认,忽略 FOXCLAW_MODEL 干扰
489
430
  self._locked_model = None
490
431
  self.model = self._locked_model or DEFAULT_MODELS.get(self.provider, "")
491
432
 
@@ -497,7 +438,6 @@ class ModelRouter:
497
438
  tier = _route_tier(agent_id, messages)
498
439
  if provider == self.provider:
499
440
  if provider == "generic":
500
- # generic 没默认 model → 必须 .env 显式给;优先级 FOXCLAW_MODEL > GENERIC_LLM_MODEL
501
441
  m = (os.environ.get("FOXCLAW_MODEL", "").strip() or
502
442
  os.environ.get("GENERIC_LLM_MODEL", "").strip())
503
443
  if m:
@@ -522,7 +462,8 @@ class ModelRouter:
522
462
  return messages
523
463
  text = msgs[idx].get("content")
524
464
  if not isinstance(text, str):
525
- return messages # 已是多模态,不重复组装
465
+ return messages
466
+ blocks: list[dict[str, Any]]
526
467
  if provider == "anthropic":
527
468
  blocks = [{"type": "text", "text": text}]
528
469
  for im in images:
@@ -537,7 +478,7 @@ class ModelRouter:
537
478
  blocks.append({"type": "image_url",
538
479
  "image_url": {"url": f"data:{mt};base64,{d}"}})
539
480
  else:
540
- return messages # google/未知:无视觉通道 → 纯文本
481
+ return messages
541
482
  msgs[idx]["content"] = blocks
542
483
  return msgs
543
484
 
@@ -558,15 +499,12 @@ class ModelRouter:
558
499
  u = getattr(resp, "usage", None)
559
500
  usage = ({"input_tokens": getattr(u, "prompt_tokens", None),
560
501
  "output_tokens": getattr(u, "completion_tokens", None)} if u else None)
561
- # 剥离内部推理块(DeepSeek-R1 / MiniMax-Text-01 / minimax-reasoner 等
562
- # 把 <think>...</think> 嵌进 content,直接落库/转发会污染用户界面与记忆)
563
502
  return _strip_think(resp.choices[0].message.content or ""), usage
564
503
  elif provider == "google":
565
504
  prompt = system + "\n\n" + "\n".join(m["content"] for m in messages)
566
505
  return client.generate_content(prompt).text, None
567
506
  raise RuntimeError(f"unknown provider {provider}")
568
507
 
569
- # ── 统一补全接口 ──────────────────────────────────────
570
508
  @staticmethod
571
509
  def _weekday_cn(dt) -> str:
572
510
  return "一二三四五六日"[dt.weekday()]
@@ -597,16 +535,15 @@ class ModelRouter:
597
535
  return self._runtime_context() + s
598
536
 
599
537
  def complete(self, system: str, messages: list, max_tokens: int = 2048,
600
- agent_id: str = "", images: list = None) -> str:
538
+ agent_id: str = "", images: list | None = None) -> str:
601
539
  """统一文本补全。messages 为 [{role, content}]。images(可选)= [{media_type,data}] 多模态图片。
602
540
 
603
541
  所有 LLM 调用的唯一咽喉——token 计量在此进行(若挂了 meter)。
604
542
  优先用 API 返回的真实 usage,拿不到则由 meter 离线估算。
605
543
  """
606
- self._maybe_reload_selection() # 前端切模型(.current_model)→ mtime 缓存 lazy reload
607
- system = self._inject_runtime(system) # 规则化:运行时事实基准统一在咽喉注入
544
+ self._maybe_reload_selection()
545
+ system = self._inject_runtime(system)
608
546
  if self.is_stub:
609
- # stub 不消耗 token,但计入 LLM 调用量(可观测);链路里也要看得见"这次走了 stub"
610
547
  try:
611
548
  from .metrics import METRICS
612
549
  METRICS.record_llm("stub", True, 0.0)
@@ -619,17 +556,17 @@ class ModelRouter:
619
556
  last_err = None
620
557
  _now = _t.monotonic()
621
558
  for i, provider in enumerate(self._fallback_order()):
622
- if self._cooldown.get(provider, 0) > _now: # 断路器:跳过刚宕机的家,免每次等超时
559
+ if self._cooldown.get(provider, 0) > _now:
623
560
  continue
624
561
  client = self._get_client(provider)
625
562
  if client is None:
626
563
  continue
627
- model = self._model_for(provider, agent_id, messages) # 按任务/Agent 选档
564
+ model = self._model_for(provider, agent_id, messages)
628
565
  _t0 = _t.perf_counter()
629
566
  try:
630
567
  from .tracing import span as _span
631
568
  with _span("llm.complete", provider=provider, model=model):
632
- if images: # 有图 → 传第 7 参数;无图保持 6 参数(兼容 test 里 6 参 monkeypatch)
569
+ if images:
633
570
  output, usage = self._call_provider(provider, client, model,
634
571
  system, messages, max_tokens, images)
635
572
  else:
@@ -637,18 +574,13 @@ class ModelRouter:
637
574
  system, messages, max_tokens)
638
575
  except Exception as e:
639
576
  self._rec_llm(provider, False, (_t.perf_counter() - _t0) * 1000.0)
640
- # 冷却时长加抖动(D 类修复):原为固定 20.0,多实例同时宕机会**同步解冻**
641
- # (惊群)。抖动后各实例解冻时间分散,上游有喘息窗口。
642
577
  self._cooldown[provider] = _t.monotonic() + (
643
578
  _COOLDOWN_BASE * (0.75 + 0.5 * _rnd.random()))
644
579
  last_err = e
645
- # 退避 + 全抖动(M6):换 provider 前让出时间,避免「快速遍历全部 provider
646
- # 再快速重来」的紧密重试风暴。首次失败不等待(保 fallback 的快速感),
647
- # 之后随尝试次数递增退避。
648
580
  if i > 0:
649
581
  _t.sleep(_backoff_delay(i))
650
- continue # → 试下一个已配 provider(fallback 链)
651
- self._cooldown.pop(provider, None) # 成功 → 解冻
582
+ continue
583
+ self._cooldown.pop(provider, None)
652
584
  self._rec_llm(provider, True, (_t.perf_counter() - _t0) * 1000.0)
653
585
  if output and getattr(self, "meter", None) is not None:
654
586
  try:
@@ -659,11 +591,6 @@ class ModelRouter:
659
591
  pass
660
592
  if i > 0:
661
593
  self.fallback_reason = f"主 provider 失败,已回退到 {provider}"
662
- # ── A 类修复:provider 返回空串 = 业务故障(内容过滤/截断/静默失败)──
663
- # 旧代码 `output or self._stub()` 会短路到 stub,而 stub 文案声称「已完成处理」
664
- # → 违反 base.py §二「真相优先」铁律,且该文本会经 base._store_memory 落入
665
- # 长期记忆,造成「一次故障 → 长期污染」(后续召回持续复读)。
666
- # 新代码:置 degraded 标志(上游据此禁写记忆),返回显式失败文本。
667
594
  if not (output or "").strip():
668
595
  self._mark_degraded(
669
596
  f"provider={provider} model={model} 返回空响应"
@@ -672,9 +599,6 @@ class ModelRouter:
672
599
  "可能原因:内容被模型安全策略过滤,或 max_tokens 过小被截断。"
673
600
  "建议:调整提问方式或提高 max_tokens 后重试。")
674
601
  return output
675
- # 全部 provider 都失败:显式失败 + 降级标志,**不再拼接 stub 成功文案**。
676
- # 保留「全部模型失败」这一精确措辞 —— 它是故障语义(而非"未配置模型"的降级),
677
- # 运维与测试都靠它识别真实故障。
678
602
  self._mark_degraded(f"全部 provider 调用失败: {last_err}")
679
603
  return (f"【全部模型失败】已尝试 {len(self._fallback_order())} 个 provider 均失败,"
680
604
  f"本次任务未完成。最后错误:"
@@ -690,7 +614,7 @@ class ModelRouter:
690
614
  pass
691
615
 
692
616
  def stream(self, system: str, messages: list, max_tokens: int = 2048,
693
- agent_id: str = "", stop_event=None, images: list = None,
617
+ agent_id: str = "", stop_event=None, images: list | None = None,
694
618
  on_thinking=None):
695
619
  """流式文本补全:逐块 yield。各 provider 用其流式 API;stub/异常降级分块。
696
620
  与 complete() 同为 LLM 咽喉,收尾做 token 计量。images(可选)= 多模态图片。
@@ -699,11 +623,8 @@ class ModelRouter:
699
623
  与正文内嵌 <think> 块都从这里出去,作为独立 SSE thinking 事件;正文不受影响。"""
700
624
  def _stopped():
701
625
  return stop_event is not None and stop_event.is_set()
702
- # 前端切模型(.current_model)→ mtime 缓存 lazy reload。
703
- # 病根:此前只有 complete() 里 reload,而聊天主路径是 stream() —— 用户在"模型选择器"
704
- # 里切完自定义模型后,流式对话仍拿旧 provider/旧 client(表现为"填了不生效")。
705
626
  self._maybe_reload_selection()
706
- system = self._inject_runtime(system) # 规则化:与 complete 同一咽喉注入运行时事实基准
627
+ system = self._inject_runtime(system)
707
628
  if self.is_stub:
708
629
  try:
709
630
  from .metrics import METRICS
@@ -726,7 +647,7 @@ class ModelRouter:
726
647
  parts = []
727
648
  import time as _t
728
649
  _t0 = _t.perf_counter()
729
- model = self._model_for(self.provider, agent_id, messages) # 按任务/Agent 选档
650
+ model = self._model_for(self.provider, agent_id, messages)
730
651
 
731
652
  def _rec(ok):
732
653
  ms = (_t.perf_counter() - _t0) * 1000.0
@@ -735,7 +656,7 @@ class ModelRouter:
735
656
  METRICS.record_llm(self.provider, ok, ms)
736
657
  except Exception:
737
658
  pass
738
- try: # 链路追踪:流式是 chat 主路径,不能漏(span 归属发起请求的 trace)
659
+ try:
739
660
  from .tracing import current_trace
740
661
  tr = current_trace()
741
662
  if tr is not None:
@@ -755,12 +676,6 @@ class ModelRouter:
755
676
  if t:
756
677
  parts.append(t); yield t
757
678
  elif self.provider in self._OPENAI_FAMILY:
758
- # P1 阶段的回归修复:此处原本只列了 openai/deepseek/qwen/local,
759
- # generic/minimax 走 else 分支退化为 complete() 一次性 yield
760
- # —— 既不是真"流式",也把
761
- # 这里使用 _OPENAI_FAMILY 与 supports_tools / tools 同步,保证
762
- # 任何 openai-compatible 厂商(minimax / generic / custom 等)都走
763
- # 真正的逐 token 流 + 在 LLM 咽喉处统一剥离 think 块。
764
679
  msgs = [{"role": "system", "content": system}] + mm
765
680
  st = self._client.chat.completions.create(
766
681
  model=model, max_tokens=max_tokens, messages=msgs, stream=True)
@@ -770,9 +685,6 @@ class ModelRouter:
770
685
  st.close() if hasattr(st, "close") else None
771
686
  return
772
687
  d = c.choices[0].delta.content if c.choices else None
773
- # 原生推理通道:deepseek-reasoner / 部分 openai-compatible 厂商把思考放在
774
- # delta.reasoning_content(少数用 reasoning)。此前整段被丢弃(只读 content),
775
- # 用户完全看不到推理;现在转成 thinking 事件,正文仍只走 content。
776
688
  if c.choices and on_thinking is not None:
777
689
  _delta = c.choices[0].delta
778
690
  _rt = (getattr(_delta, "reasoning_content", None)
@@ -784,7 +696,7 @@ class ModelRouter:
784
696
  parts.append(d)
785
697
  cleaned, _think_txt = stripper.feed_events(d)
786
698
  if _think_txt and on_thinking is not None:
787
- on_thinking(_think_txt) # 内嵌 <think> 块 → 推理事件,不进正文
699
+ on_thinking(_think_txt)
788
700
  if cleaned:
789
701
  yield cleaned
790
702
  elif self.provider == "google":
@@ -799,8 +711,6 @@ class ModelRouter:
799
711
  parts.append(out); yield out
800
712
  except Exception as e:
801
713
  _rec(False)
802
- # 脱敏(M1 A 类修复):不把原始异常对象吐给用户 —— 它可能含内部端点、
803
- # 文件路径或请求细节。只给异常类型 + 降级语义,完整原因进 tracing。
804
714
  self._mark_degraded(f"stream 失败: {type(e).__name__}")
805
715
  yield f"【模型流式中断】{type(e).__name__},本次未获得完整回复。\n"
806
716
  for w in _chunk_text(self._stub(messages)):
@@ -817,15 +727,6 @@ class ModelRouter:
817
727
  except Exception:
818
728
  pass
819
729
 
820
- # ── P2 function-calling(工具调用)──────────────────────────────────────────
821
- # 病根修复:让模型**自己决定**调哪个工具,取代"调 LLM 前用关键词表拍板"。
822
- # 模型无关:openai 家族(openai/deepseek/qwen/local,含 openai-compatible 万能通道)走
823
- # OpenAI tools 格式;anthropic 走原生 tool_use block(P0-2);google/stub 不支持 →
824
- # supported=False,由上层自动回退关键词增强链(fallback,不破坏)。
825
- # ⚠ "custom"(前端选中的自定义模型)必须在这里 —— 它是**运行时**才出现的 openai-compatible
826
- # 通道,漏掉会让 stream() 退化为 complete() 一次性输出(前端表现为"不流式"),且
827
- # supports_tools() 变 False → 工具体系静默降级。凡新增 openai 兼容 provider,
828
- # 只改这一处(调用点已统一引用本元组)。
829
730
  _OPENAI_FAMILY = ("openai", "deepseek", "qwen", "local", "generic", "minimax", "custom")
830
731
  _TOOL_PROVIDERS = _OPENAI_FAMILY + ("anthropic",)
831
732
 
@@ -900,11 +801,9 @@ class ModelRouter:
900
801
  model, system, messages, tools, tool_choice, max_tokens)
901
802
  except Exception as e:
902
803
  self._rec_llm(self.provider, False, (_t.perf_counter() - _t0) * 1000.0)
903
- # 工具调用失败(端点不支持 tools 等)→ 当作不支持,让上层降级
904
804
  return {"supported": False, "text": "", "tool_calls": [],
905
805
  "assistant_msg": None, "error": str(e)}
906
806
  self._rec_llm(self.provider, True, (_t.perf_counter() - _t0) * 1000.0)
907
- # 计量(工具决策轮也算调用)
908
807
  if getattr(self, "meter", None) is not None:
909
808
  try:
910
809
  self.meter.measure_call(system=system, messages=messages,
@@ -932,15 +831,10 @@ class ModelRouter:
932
831
  try:
933
832
  args = _json.loads(raw_args)
934
833
  if not isinstance(args, dict):
935
- # 模型偶发返回数组/标量(JSON 合法但结构不符)→ 同样按解析失败处理
936
834
  args, parse_failed = {}, True
937
835
  except Exception:
938
836
  args, parse_failed = {}, True
939
837
  if parse_failed:
940
- # 静默 {} 会让模型以为工具已执行(实际参数全丢)→ 错误必须可见。
941
- # 此处**保留空 args 继续分发**:由 base._dispatch_tool 的参数校验闸
942
- # 把错误回注给模型,下一轮即可自修正;这里额外记 span,便于事后归因
943
- # (此前完全无痕迹,模型错了都不知道)。
944
838
  try:
945
839
  from .tracing import span as _span
946
840
  with _span("llm.tool_args_unparsable",
@@ -1027,7 +921,6 @@ class ModelRouter:
1027
921
  f"恢复方式:配置任一模型 Key(见 .env.example)后重试。\n"
1028
922
  f"(STUB · 此为降级占位,非真实结果)")
1029
923
 
1030
- # ── 降级状态(A 类修复)────────────────────────────────────────────
1031
924
  def _mark_degraded(self, reason: str) -> None:
1032
925
  """标记本次调用为「故障降级」(区别于 is_stub 的「从未配置」)。
1033
926
 
@@ -1061,11 +954,11 @@ class ModelRouter:
1061
954
  self._call_degraded = False
1062
955
  return v
1063
956
 
1064
- def switch(self, provider: str, model: str = None):
957
+ def switch(self, provider: str, model: str | None = None):
1065
958
  """运行时切换 provider(多模型路由的核心能力)。
1066
959
  显式给 model → 锁定该模型(不分档,尊重用户选择);只切 provider(model 空)→ 清锁用分档。"""
1067
960
  self.provider = provider
1068
- self._locked_model = (model or "").strip() # 修复:显式模型必须生效(否则被 tiering 覆盖)
961
+ self._locked_model = (model or "").strip()
1069
962
  self.model = self._locked_model or DEFAULT_MODELS.get(provider, self.model)
1070
963
  self._init_client()
1071
964
  return self.info()
@@ -1075,11 +968,6 @@ class ModelRouter:
1075
968
  "provider": self.provider,
1076
969
  "model": self.model,
1077
970
  "active": not self.is_stub,
1078
- # ── 降级可见性(A 类修复)────────────────────────────────
1079
- # 原先只报 provider + active。当 provider=anthropic 但 SDK 缺失时,
1080
- # 运维看到的是「配置了但没激活」,真实原因(SDK 未安装)被吞掉。
1081
- # degraded / degraded_reason / fallback_reason / client_ready 四者一起,
1082
- # 才说得清「为什么没模型可用」。
1083
971
  "degraded": self.degraded,
1084
972
  "degraded_reason": self.degraded_reason[:200],
1085
973
  "fallback_reason": self.fallback_reason,
@@ -49,7 +49,7 @@ def allowed_import_roots() -> list[Path]:
49
49
  try:
50
50
  roots.append(Path(item).expanduser().resolve())
51
51
  except (OSError, RuntimeError):
52
- continue # 无效配置不值当让请求 500,忽略即可(settings check 会提示)
52
+ continue
53
53
  return roots
54
54
 
55
55