foxclaw 0.1.0 → 0.1.2
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.
- package/README.md +54 -29
- package/bin/foxclaw.cjs +51 -0
- package/index.js +11 -10
- package/package.json +46 -38
- package/python/.dockerignore +35 -0
- package/python/.env.example +165 -0
- package/python/.gitattributes +46 -0
- package/python/.github/workflows/ci.yml +122 -0
- package/python/.github/workflows/release.yml +183 -0
- package/python/Dockerfile +64 -0
- package/python/LICENSE +21 -0
- package/python/MANIFEST.in +14 -0
- package/python/README.md +192 -0
- package/python/README_CN.md +324 -0
- package/python/bench/bm25_audit.py +199 -0
- package/python/bench/bm25_regression.py +178 -0
- package/python/bench/bm25_stress.py +159 -0
- package/python/bench/cd_audit.py +238 -0
- package/python/bench/chaos.py +142 -0
- package/python/bench/concurrency.py +103 -0
- package/python/bench/e2e_real_models.py +95 -0
- package/python/bench/llm_retry_test.py +248 -0
- package/python/bench/load_test.py +196 -0
- package/python/bench/vector_ivf_upgrade.py +134 -0
- package/python/docs/CHANNELS.md +195 -0
- package/python/docs/GOVERNANCE.md +164 -0
- package/python/docs/RELEASE.md +109 -0
- package/python/docs/RUN.md +75 -0
- package/python/docs/RUN_CN.md +121 -0
- package/python/docs//351/205/215/347/275/256/346/214/207/345/215/227.md +152 -0
- package/python/evals/__init__.py +0 -0
- package/python/evals/governance/README.md +61 -0
- package/python/evals/governance/__init__.py +0 -0
- package/python/evals/governance/cases.py +152 -0
- package/python/evals/governance/compare_embeddings.py +94 -0
- package/python/evals/governance/convergence.svg +11 -0
- package/python/evals/governance/report.json +371 -0
- package/python/evals/governance/run_evals.py +400 -0
- package/python/examples/governance_middleware_demo.py +77 -0
- package/python/examples/langgraph_governance_demo.py +108 -0
- package/python/examples/sample_itinerary.csv +6 -0
- package/python/examples/sample_knowledge.csv +9 -0
- package/python/examples/sample_transactions.csv +14 -0
- package/python/foxclaw/__init__.py +526 -0
- package/python/foxclaw/agents/__init__.py +12 -0
- package/python/foxclaw/agents/agents.py +35 -0
- package/python/foxclaw/agents/base.py +865 -0
- package/python/foxclaw/agents/impl/__init__.py +1 -0
- package/python/foxclaw/agents/impl/_helpers.py +78 -0
- package/python/foxclaw/agents/impl/finance.py +248 -0
- package/python/foxclaw/agents/impl/office.py +367 -0
- package/python/foxclaw/agents/impl/supervisor.py +659 -0
- package/python/foxclaw/agents/mixins/_context.py +151 -0
- package/python/foxclaw/agents/mixins/augment.py +149 -0
- package/python/foxclaw/agents/mixins/llm.py +66 -0
- package/python/foxclaw/agents/mixins/memory.py +35 -0
- package/python/foxclaw/agents/mixins/preference.py +81 -0
- package/python/foxclaw/agents/mixins/prompt.py +77 -0
- package/python/foxclaw/agents/mixins/skillbox.py +58 -0
- package/python/foxclaw/agents/mixins/tool_exec.py +443 -0
- package/python/foxclaw/api/__init__.py +283 -0
- package/python/foxclaw/api/app.py +123 -0
- package/python/foxclaw/api/middleware.py +317 -0
- package/python/foxclaw/api/routes/ahs.py +417 -0
- package/python/foxclaw/api/routes/attachments.py +117 -0
- package/python/foxclaw/api/routes/browser.py +39 -0
- package/python/foxclaw/api/routes/domain.py +314 -0
- package/python/foxclaw/api/routes/knowledge.py +163 -0
- package/python/foxclaw/api/routes/llm_admin.py +461 -0
- package/python/foxclaw/api/routes/memory.py +89 -0
- package/python/foxclaw/api/routes/orchestration.py +41 -0
- package/python/foxclaw/api/routes/recovery.py +43 -0
- package/python/foxclaw/api/routes/review.py +275 -0
- package/python/foxclaw/api/routes/search.py +37 -0
- package/python/foxclaw/api/routes/system.py +327 -0
- package/python/foxclaw/api/routes/task.py +281 -0
- package/python/foxclaw/api/routes/ws.py +68 -0
- package/python/foxclaw/api/spine.py +55 -0
- package/python/foxclaw/browser/__init__.py +13 -0
- package/python/foxclaw/browser/action/__init__.py +1 -0
- package/python/foxclaw/browser/action/action_executor.py +54 -0
- package/python/foxclaw/browser/action/action_types.py +45 -0
- package/python/foxclaw/browser/action/action_verifier.py +68 -0
- package/python/foxclaw/browser/action/url_policy.py +147 -0
- package/python/foxclaw/browser/adapter/__init__.py +1 -0
- package/python/foxclaw/browser/adapter/base_adapter.py +23 -0
- package/python/foxclaw/browser/adapter/web_adapter.py +46 -0
- package/python/foxclaw/browser/driver/__init__.py +1 -0
- package/python/foxclaw/browser/driver/base_driver.py +61 -0
- package/python/foxclaw/browser/driver/playwright_driver.py +166 -0
- package/python/foxclaw/browser/perception/__init__.py +1 -0
- package/python/foxclaw/browser/perception/dom_extractor.py +43 -0
- package/python/foxclaw/browser/perception/element_indexer.py +57 -0
- package/python/foxclaw/browser/perception/semantic_reducer.py +33 -0
- package/python/foxclaw/browser/perception/snapshot.py +29 -0
- package/python/foxclaw/browser/service.py +148 -0
- package/python/foxclaw/browser/session/__init__.py +1 -0
- package/python/foxclaw/browser/session/session_manager.py +57 -0
- package/python/foxclaw/capability/__init__.py +12 -0
- package/python/foxclaw/capability/embedding_service.py +67 -0
- package/python/foxclaw/channels/__init__.py +140 -0
- package/python/foxclaw/channels/discord.py +184 -0
- package/python/foxclaw/channels/dispatcher.py +204 -0
- package/python/foxclaw/channels/feishu.py +225 -0
- package/python/foxclaw/channels/openclaw_bridge.py +156 -0
- package/python/foxclaw/channels/signing.py +187 -0
- package/python/foxclaw/channels/telegram.py +121 -0
- package/python/foxclaw/channels/weixin.py +225 -0
- package/python/foxclaw/cli.py +154 -0
- package/python/foxclaw/cognitive/__init__.py +4 -0
- package/python/foxclaw/cognitive/reasoner.py +82 -0
- package/python/foxclaw/cognitive/state.py +147 -0
- package/python/foxclaw/core/__init__.py +9 -0
- package/python/foxclaw/core/audit_chain.py +133 -0
- package/python/foxclaw/core/audit_sink.py +100 -0
- package/python/foxclaw/core/compaction.py +283 -0
- package/python/foxclaw/core/config.py +65 -0
- package/python/foxclaw/core/confirm_nonce.py +157 -0
- package/python/foxclaw/core/dispatch.py +27 -0
- package/python/foxclaw/core/envfile.py +48 -0
- package/python/foxclaw/core/failure_policy.py +151 -0
- package/python/foxclaw/core/gateway.py +85 -0
- package/python/foxclaw/core/governance_audit.py +175 -0
- package/python/foxclaw/core/idempotency.py +289 -0
- package/python/foxclaw/core/injection_guard.py +221 -0
- package/python/foxclaw/core/memory_adapter.py +516 -0
- package/python/foxclaw/core/metrics.py +89 -0
- package/python/foxclaw/core/model_router.py +1097 -0
- package/python/foxclaw/core/paths.py +103 -0
- package/python/foxclaw/core/recovery.py +362 -0
- package/python/foxclaw/core/scheduler.py +144 -0
- package/python/foxclaw/core/secrets.py +134 -0
- package/python/foxclaw/core/sessions.py +157 -0
- package/python/foxclaw/core/settings.py +430 -0
- package/python/foxclaw/core/shared_write.py +198 -0
- package/python/foxclaw/core/steering_queue.py +153 -0
- package/python/foxclaw/core/system_bus.py +84 -0
- package/python/foxclaw/core/thought_chain.py +318 -0
- package/python/foxclaw/core/token_meter.py +235 -0
- package/python/foxclaw/core/tools.py +172 -0
- package/python/foxclaw/core/tracing.py +390 -0
- package/python/foxclaw/core/truth_pipeline.py +114 -0
- package/python/foxclaw/core/vec_recall_adapter.py +503 -0
- package/python/foxclaw/core/verify.py +181 -0
- package/python/foxclaw/core/web_search.py +242 -0
- package/python/foxclaw/finance/__init__.py +15 -0
- package/python/foxclaw/finance/evidence.py +153 -0
- package/python/foxclaw/finance/fx.py +100 -0
- package/python/foxclaw/finance/graph.py +188 -0
- package/python/foxclaw/finance/models.py +150 -0
- package/python/foxclaw/finance/nlparse.py +575 -0
- package/python/foxclaw/finance/policy.py +329 -0
- package/python/foxclaw/finance/reasoner.py +129 -0
- package/python/foxclaw/finance/reasoning.py +340 -0
- package/python/foxclaw/finance/reconcile.py +180 -0
- package/python/foxclaw/finance/runtime.py +527 -0
- package/python/foxclaw/governance/__init__.py +24 -0
- package/python/foxclaw/governance/middleware.py +184 -0
- package/python/foxclaw/harness/__init__.py +17 -0
- package/python/foxclaw/harness/_base.py +72 -0
- package/python/foxclaw/harness/_policy.py +359 -0
- package/python/foxclaw/harness/drift.py +89 -0
- package/python/foxclaw/harness/event_bus.py +31 -0
- package/python/foxclaw/harness/harness.py +446 -0
- package/python/foxclaw/harness/loop_detection.py +152 -0
- package/python/foxclaw/itinerary/__init__.py +10 -0
- package/python/foxclaw/itinerary/amap_client.py +117 -0
- package/python/foxclaw/itinerary/models.py +80 -0
- package/python/foxclaw/itinerary/reasoner.py +101 -0
- package/python/foxclaw/itinerary/reasoning.py +174 -0
- package/python/foxclaw/itinerary/runtime.py +148 -0
- package/python/foxclaw/kernel/__init__.py +13 -0
- package/python/foxclaw/kernel/capabilities.py +73 -0
- package/python/foxclaw/kernel/policy_gate.py +114 -0
- package/python/foxclaw/kernel/process_manager.py +205 -0
- package/python/foxclaw/kernel/scheduler.py +131 -0
- package/python/foxclaw/kernel/sentinel.py +453 -0
- package/python/foxclaw/kernel/sentinel_client.py +246 -0
- package/python/foxclaw/kernel/spine.py +97 -0
- package/python/foxclaw/knowledge/__init__.py +376 -0
- package/python/foxclaw/knowledge/truth_crystallizer.py +92 -0
- package/python/foxclaw/learning/__init__.py +12 -0
- package/python/foxclaw/learning/knowledge_graph.py +149 -0
- package/python/foxclaw/learning/models.py +93 -0
- package/python/foxclaw/learning/reasoner.py +70 -0
- package/python/foxclaw/learning/runtime.py +363 -0
- package/python/foxclaw/learning/spaced_repetition.py +96 -0
- package/python/foxclaw/mcp_server.py +189 -0
- package/python/foxclaw/orchestration/__init__.py +184 -0
- package/python/foxclaw/pipeline/__init__.py +23 -0
- package/python/foxclaw/pipeline/cognitive_pipeline.py +481 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +230 -0
- package/python/foxclaw/planning/__init__.py +17 -0
- package/python/foxclaw/planning/llm_planner.py +232 -0
- package/python/foxclaw/planning/planner.py +99 -0
- package/python/foxclaw/search/__init__.py +24 -0
- package/python/foxclaw/search/agent_port.py +194 -0
- package/python/foxclaw/search/evidence.py +113 -0
- package/python/foxclaw/search/external_search.py +50 -0
- package/python/foxclaw/search/hkc_read_port.py +60 -0
- package/python/foxclaw/search/internal_search.py +73 -0
- package/python/foxclaw/search/models.py +129 -0
- package/python/foxclaw/search/planner.py +44 -0
- package/python/foxclaw/search/retriever.py +39 -0
- package/python/foxclaw/search/runtime.py +134 -0
- package/python/foxclaw/search/strategy.py +44 -0
- package/python/foxclaw/skills/__init__.py +613 -0
- package/python/foxclaw/skills/hooks_spec.py +73 -0
- package/python/foxclaw/skills/lifecycle.py +278 -0
- package/python/foxclaw/skills/markdown_skill.py +174 -0
- package/python/foxclaw/skills/markdown_store.py +39 -0
- package/python/foxclaw/skills/mcp_client.py +190 -0
- package/python/foxclaw/skills/mcp_store.py +43 -0
- package/python/foxclaw/skills/permission.py +180 -0
- package/python/foxclaw/skills/pipeline.py +386 -0
- package/python/foxclaw/skills/policy.py +80 -0
- package/python/foxclaw/skills/review.py +72 -0
- package/python/foxclaw/skills/store.py +57 -0
- package/python/frontend/assets/core_bg.jpg +0 -0
- package/python/frontend/deck/auth.js +32 -0
- package/python/frontend/deck/chat_model_picker.js +537 -0
- package/python/frontend/deck/domain.js +296 -0
- package/python/frontend/deck/main.js +1950 -0
- package/python/frontend/deck/markdown.js +48 -0
- package/python/frontend/deck/settings.js +324 -0
- package/python/frontend/deck/state.js +6 -0
- package/python/frontend/deck/util.js +6 -0
- package/python/frontend/deck/visuals.js +212 -0
- package/python/frontend/deck.css +855 -0
- package/python/frontend/deck.html +686 -0
- package/python/frontend/deck_v2.html +341 -0
- package/python/frontend/planet/index.html +266 -0
- package/python/frontend/planet/planet.css +579 -0
- package/python/frontend/planet/planet.js +1637 -0
- package/python/frontend/planet/vendor/three.core.min.js +6 -0
- package/python/frontend/planet/vendor/three.module.min.js +6 -0
- package/python/frontend/v3/assets/index-BD7VPbMV.js +33 -0
- package/python/frontend/v3/assets/index-CAvnYWGH.css +1 -0
- package/python/frontend/v3/index.html +15 -0
- package/python/frontend/widget_runtime.js +295 -0
- package/python/frontend/widgets.js +696 -0
- package/python/frontend_src/index.html +14 -0
- package/python/frontend_src/package-lock.json +1376 -0
- package/python/frontend_src/package.json +19 -0
- package/python/frontend_src/src/App.vue +62 -0
- package/python/frontend_src/src/api.js +89 -0
- package/python/frontend_src/src/components/ChatDock.vue +377 -0
- package/python/frontend_src/src/components/ComposerMenu.vue +75 -0
- package/python/frontend_src/src/components/DocViewer.vue +171 -0
- package/python/frontend_src/src/components/Inspector.vue +241 -0
- package/python/frontend_src/src/components/LeftRail.vue +394 -0
- package/python/frontend_src/src/components/MenuPopover.vue +130 -0
- package/python/frontend_src/src/components/MessageStream.vue +401 -0
- package/python/frontend_src/src/components/ModelPicker.vue +355 -0
- package/python/frontend_src/src/components/QuickIngest.vue +118 -0
- package/python/frontend_src/src/components/Stage.vue +226 -0
- package/python/frontend_src/src/components/ToolMenu.vue +144 -0
- package/python/frontend_src/src/components/TopBar.vue +90 -0
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +84 -0
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +86 -0
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +441 -0
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +118 -0
- package/python/frontend_src/src/components/panels/PanelBox.vue +51 -0
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +308 -0
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +254 -0
- package/python/frontend_src/src/components/panels/TasksPanel.vue +74 -0
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +640 -0
- package/python/frontend_src/src/components/panels/UsagePanel.vue +105 -0
- package/python/frontend_src/src/lib/fmt.js +48 -0
- package/python/frontend_src/src/lib/md.js +204 -0
- package/python/frontend_src/src/main.js +7 -0
- package/python/frontend_src/src/stores/app.js +106 -0
- package/python/frontend_src/src/stores/chat.js +484 -0
- package/python/frontend_src/src/stores/model.js +104 -0
- package/python/frontend_src/src/stores/panels.js +330 -0
- package/python/frontend_src/src/styles/base.css +138 -0
- package/python/frontend_src/src/styles/panel.css +117 -0
- package/python/frontend_src/src/styles/tokens.css +78 -0
- package/python/frontend_src/vite.config.js +46 -0
- package/python/pyproject.toml +265 -0
- package/python/skills/README.md +123 -0
- package/python/skills/execution-contract/SKILL.md +30 -0
- package/python/skills/general-assistant/SKILL.md +34 -0
- package/python/skills/planner/SKILL.md +33 -0
- package/python/skills/schedule-itinerary/SKILL.md +35 -0
- package/python/skills/skill-composition/SKILL.md +31 -0
- package/python/skills/skill-guard/SKILL.md +67 -0
- package/python/skills/skill-memory/SKILL.md +50 -0
- package/python/skills/skill-router/SKILL.md +35 -0
- package/python/tests/conftest.py +76 -0
- package/python/tests/run_all.py +263 -0
- package/python/tests/test_agent_lifecycle.py +360 -0
- package/python/tests/test_ahs_p2.py +414 -0
- package/python/tests/test_anthropic_tools.py +206 -0
- package/python/tests/test_api.py +87 -0
- package/python/tests/test_api_auth.py +83 -0
- package/python/tests/test_api_host_guard.py +69 -0
- package/python/tests/test_attachments.py +167 -0
- package/python/tests/test_audit_chain.py +121 -0
- package/python/tests/test_audit_sink.py +136 -0
- package/python/tests/test_browser.py +153 -0
- package/python/tests/test_browser_request_guard.py +184 -0
- package/python/tests/test_capabilities.py +158 -0
- package/python/tests/test_channel_signing.py +192 -0
- package/python/tests/test_channel_trust.py +109 -0
- package/python/tests/test_channels.py +184 -0
- package/python/tests/test_cli.py +46 -0
- package/python/tests/test_compaction_toolpair.py +119 -0
- package/python/tests/test_config_layering.py +65 -0
- package/python/tests/test_confirm_nonce.py +197 -0
- package/python/tests/test_context_compaction_wiring.py +130 -0
- package/python/tests/test_dispatch.py +95 -0
- package/python/tests/test_domain_evals.py +403 -0
- package/python/tests/test_domain_import.py +78 -0
- package/python/tests/test_domain_reasoners.py +148 -0
- package/python/tests/test_e2e.py +178 -0
- package/python/tests/test_emb_cache.py +107 -0
- package/python/tests/test_embedding_singleton.py +89 -0
- package/python/tests/test_envfile.py +83 -0
- package/python/tests/test_evals_smoke.py +114 -0
- package/python/tests/test_event_spine.py +182 -0
- package/python/tests/test_evidence_semantic.py +101 -0
- package/python/tests/test_evidence_sse.py +97 -0
- package/python/tests/test_file_ingest.py +146 -0
- package/python/tests/test_finance.py +153 -0
- package/python/tests/test_finance_ledger.py +252 -0
- package/python/tests/test_finance_persist.py +178 -0
- package/python/tests/test_gateway.py +144 -0
- package/python/tests/test_governance.py +468 -0
- package/python/tests/test_governance_audit.py +359 -0
- package/python/tests/test_governance_middleware.py +143 -0
- package/python/tests/test_hkc.py +286 -0
- package/python/tests/test_hkc_index_build_manager.py +339 -0
- package/python/tests/test_hkc_manifest_reuse.py +317 -0
- package/python/tests/test_hkc_search_event_sync.py +533 -0
- package/python/tests/test_import_path_guard.py +161 -0
- package/python/tests/test_injection_guard.py +211 -0
- package/python/tests/test_integration.py +141 -0
- package/python/tests/test_itinerary.py +103 -0
- package/python/tests/test_kee_semantic.py +89 -0
- package/python/tests/test_kernel.py +222 -0
- package/python/tests/test_learning.py +110 -0
- package/python/tests/test_llm_admin.py +160 -0
- package/python/tests/test_llm_planner.py +366 -0
- package/python/tests/test_markdown_skills.py +73 -0
- package/python/tests/test_markdown_skills_openclaw.py +71 -0
- package/python/tests/test_mcp_connectors.py +109 -0
- package/python/tests/test_mcp_guardian_discovery.py +91 -0
- package/python/tests/test_mcp_no_memory.py +43 -0
- package/python/tests/test_mcp_policy_gate.py +77 -0
- package/python/tests/test_mcp_server.py +95 -0
- package/python/tests/test_memory_recall.py +95 -0
- package/python/tests/test_metrics.py +66 -0
- package/python/tests/test_multi_chain.py +102 -0
- package/python/tests/test_multimodal.py +135 -0
- package/python/tests/test_negotiation.py +146 -0
- package/python/tests/test_orchestration.py +119 -0
- package/python/tests/test_orchestration_decoupled.py +109 -0
- package/python/tests/test_p3_p5_hardening.py +338 -0
- package/python/tests/test_parallel_dispatch.py +215 -0
- package/python/tests/test_pipeline.py +533 -0
- package/python/tests/test_plan_visible.py +86 -0
- package/python/tests/test_planner.py +95 -0
- package/python/tests/test_policy_gate.py +109 -0
- package/python/tests/test_reasoner.py +185 -0
- package/python/tests/test_release_check.py +263 -0
- package/python/tests/test_resume_checkpoint.py +156 -0
- package/python/tests/test_routing.py +95 -0
- package/python/tests/test_scheduler.py +141 -0
- package/python/tests/test_search.py +204 -0
- package/python/tests/test_secrets.py +159 -0
- package/python/tests/test_semantic.py +78 -0
- package/python/tests/test_sessions.py +91 -0
- package/python/tests/test_settings.py +281 -0
- package/python/tests/test_shadow_learning.py +330 -0
- package/python/tests/test_shared_write.py +207 -0
- package/python/tests/test_skill_taint_gate.py +154 -0
- package/python/tests/test_skills.py +133 -0
- package/python/tests/test_stats_and_events.py +165 -0
- package/python/tests/test_streaming.py +60 -0
- package/python/tests/test_supervisor_pipeline.py +177 -0
- package/python/tests/test_supervisor_triage.py +108 -0
- package/python/tests/test_taint.py +117 -0
- package/python/tests/test_task_stream.py +85 -0
- package/python/tests/test_thinking_sse.py +67 -0
- package/python/tests/test_thinking_stream.py +76 -0
- package/python/tests/test_tool_center.py +129 -0
- package/python/tests/test_tool_parallel.py +113 -0
- package/python/tests/test_tool_params.py +103 -0
- package/python/tests/test_tool_use.py +242 -0
- package/python/tests/test_toplevel_concurrency.py +117 -0
- package/python/tests/test_tracing.py +290 -0
- package/python/tests/test_truth_crystallizer.py +127 -0
- package/python/tests/test_truth_flow.py +93 -0
- package/python/tests/test_url_policy.py +53 -0
- package/python/tests/test_verify_evidence.py +225 -0
- package/python/tests/test_version_sync.py +71 -0
- package/python/tests/test_web_trigger.py +99 -0
- package/python/tools/check_faiss_type.py +31 -0
- package/python/tools/check_log.ps1 +46 -0
- package/python/tools/ivf_status.py +72 -0
- package/python/tools/mypy_baseline.txt +5 -0
- package/python/tools/mypy_ratchet.py +97 -0
- package/python/tools/pack.py +178 -0
- package/python/tools/release_check.py +349 -0
- package/python/tools/restart_foxclaw.ps1 +74 -0
- package/python/tools/shared_write_audit.py +204 -0
- package/python/tools/size_baseline.txt +28 -0
- package/python/tools/size_check.py +102 -0
- package/python/tools/verify_env.bat +2 -0
- package/python/tools/verify_env.py +25 -0
- package/python/vendor/HMR_LICENSE +37 -0
- package/python/vendor/HMR_README.md +65 -0
- package/python/vendor/hkc/LICENSE +37 -0
- package/python/vendor/hkc/hkc_ace/__init__.py +0 -0
- package/python/vendor/hkc/hkc_ace/ace.py +457 -0
- package/python/vendor/hkc/hkc_ace/skill_taxonomy.json +465 -0
- package/python/vendor/hkc/hkc_api/__init__.py +0 -0
- package/python/vendor/hkc/hkc_api/container.py +416 -0
- package/python/vendor/hkc/hkc_api/ingest_jobs.py +103 -0
- package/python/vendor/hkc/hkc_api/main.py +157 -0
- package/python/vendor/hkc/hkc_api/routes/__init__.py +0 -0
- package/python/vendor/hkc/hkc_api/routes/ability.py +151 -0
- package/python/vendor/hkc/hkc_api/routes/config.py +51 -0
- package/python/vendor/hkc/hkc_api/routes/knowledge.py +630 -0
- package/python/vendor/hkc/hkc_api/routes/search.py +88 -0
- package/python/vendor/hkc/hkc_api/schemas.py +153 -0
- package/python/vendor/hkc/hkc_api/synthesis.py +179 -0
- package/python/vendor/hkc/hkc_core/__init__.py +0 -0
- package/python/vendor/hkc/hkc_core/graph/__init__.py +0 -0
- package/python/vendor/hkc/hkc_core/graph/base.py +188 -0
- package/python/vendor/hkc/hkc_core/graph/sqlite_store.py +789 -0
- package/python/vendor/hkc/hkc_core/models/__init__.py +0 -0
- package/python/vendor/hkc/hkc_core/models/enums.py +65 -0
- package/python/vendor/hkc/hkc_core/models/ku.py +307 -0
- package/python/vendor/hkc/hkc_core/utils/__init__.py +0 -0
- package/python/vendor/hkc/hkc_core/utils/id_gen.py +113 -0
- package/python/vendor/hkc/hkc_crystallizer/__init__.py +44 -0
- package/python/vendor/hkc/hkc_crystallizer/adapters.py +94 -0
- package/python/vendor/hkc/hkc_crystallizer/candidate.py +145 -0
- package/python/vendor/hkc/hkc_crystallizer/crystallizer.py +179 -0
- package/python/vendor/hkc/hkc_crystallizer/ingress.py +90 -0
- package/python/vendor/hkc/hkc_kde/__init__.py +0 -0
- package/python/vendor/hkc/hkc_kde/chunker.py +197 -0
- package/python/vendor/hkc/hkc_kde/extractor.py +623 -0
- package/python/vendor/hkc/hkc_kde/kde.py +298 -0
- package/python/vendor/hkc/hkc_kde/loaders/__init__.py +0 -0
- package/python/vendor/hkc/hkc_kde/loaders/ebook_loader.py +153 -0
- package/python/vendor/hkc/hkc_kde/loaders/office_loader.py +194 -0
- package/python/vendor/hkc/hkc_kde/loaders/pdf_loader.py +162 -0
- package/python/vendor/hkc/hkc_kde/loaders/text_loader.py +157 -0
- package/python/vendor/hkc/hkc_kde/loaders/web_loader.py +91 -0
- package/python/vendor/hkc/hkc_kde/models.py +74 -0
- package/python/vendor/hkc/hkc_kde/packager.py +329 -0
- package/python/vendor/hkc/hkc_kde/relation_disc.py +151 -0
- package/python/vendor/hkc/hkc_kee/__init__.py +0 -0
- package/python/vendor/hkc/hkc_kee/dedup.py +227 -0
- package/python/vendor/hkc/hkc_kee/fingerprint.py +106 -0
- package/python/vendor/hkc/hkc_kee/kee.py +541 -0
- package/python/vendor/hkc/hkc_kep/__init__.py +0 -0
- package/python/vendor/hkc/hkc_kep/event_bus.py +205 -0
- package/python/vendor/hkc/hkc_sdk/__init__.py +63 -0
- package/python/vendor/hkc/hkc_sdk/base.py +151 -0
- package/python/vendor/hkc/hkc_sdk/direct_client.py +153 -0
- package/python/vendor/hkc/hkc_sdk/exceptions.py +39 -0
- package/python/vendor/hkc/hkc_sdk/http_client.py +166 -0
- package/python/vendor/hkc/hkc_sdk/models.py +171 -0
- package/python/vendor/hkc/hkc_search/__init__.py +0 -0
- package/python/vendor/hkc/hkc_search/bm25.py +325 -0
- package/python/vendor/hkc/hkc_search/embedding_backends.py +374 -0
- package/python/vendor/hkc/hkc_search/graph_search.py +132 -0
- package/python/vendor/hkc/hkc_search/hybrid.py +331 -0
- package/python/vendor/hkc/hkc_search/index_build_manager.py +378 -0
- package/python/vendor/hkc/hkc_search/vector_search.py +484 -0
- package/python/vendor/hmr/__init__.py +0 -0
- package/python/vendor/hmr/core/__init__.py +0 -0
- package/python/vendor/hmr/core/hmr.py +684 -0
- package/python/vendor/hmr/core/models.py +310 -0
- package/python/vendor/hmr/engines/__init__.py +0 -0
- package/python/vendor/hmr/engines/evolution.py +710 -0
- package/python/vendor/hmr/engines/jit_compiler.py +472 -0
- package/python/vendor/hmr/engines/lifecycle.py +264 -0
- package/python/vendor/hmr/engines/policy.py +625 -0
- package/python/vendor/hmr/engines/recall.py +260 -0
- package/python/vendor/hmr/engines/runtime_state.py +158 -0
- package/python/vendor/hmr/engines/scheduler.py +323 -0
- package/python/vendor/hmr/engines/semantic.py +150 -0
- package/python/vendor/hmr/engines/temporal.py +261 -0
- package/python/vendor/hmr/engines/thought_chain.py +635 -0
- package/python/vendor/hmr/graph/__init__.py +0 -0
- package/python/vendor/hmr/graph/cwg.py +338 -0
- package/python/vendor/hmr/graph/memory_graph.py +530 -0
- package/python/vendor/hmr/storage/__init__.py +0 -0
- package/python/vendor/hmr/storage/memory_fs.py +300 -0
- package/python/vendor/hmr/storage/vector_store.py +520 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Foxclaw — 多模型路由(Model Router)
|
|
3
|
+
体现 Foxclaw 的核心架构哲学:认知层与 LLM 解耦,大模型只是可替换的文本生成器。
|
|
4
|
+
所有 Agent 通过 Router 调模型,换模型不影响 ThoughtChain / Harness / 领域内核。
|
|
5
|
+
|
|
6
|
+
支持的 provider:
|
|
7
|
+
- anthropic Claude (ANTHROPIC_API_KEY)
|
|
8
|
+
- openai GPT (OPENAI_API_KEY)
|
|
9
|
+
- deepseek DeepSeek (DEEPSEEK_API_KEY,OpenAI 兼容接口;界面推荐)
|
|
10
|
+
- google Gemini (GOOGLE_API_KEY)
|
|
11
|
+
- qwen 通义千问 (DASHSCOPE_API_KEY,OpenAI 兼容接口)
|
|
12
|
+
- local 本地模型 (LOCAL_LLM_BASE,如 Ollama / vLLM,OpenAI 兼容)
|
|
13
|
+
- stub 无 key 降级 (始终可用,保证系统可独立运行)
|
|
14
|
+
|
|
15
|
+
每个 provider 用各自 SDK,但对外暴露统一的 complete(system, messages) 接口。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import json
|
|
20
|
+
import threading
|
|
21
|
+
import re as _re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
24
|
+
|
|
25
|
+
|
|
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
|
+
def normalize_base_url(url: str) -> str:
|
|
35
|
+
"""把用户填的 BASE URL 收拾成 SDK 可直接用的形式(带 scheme + 至少一段 path)。
|
|
36
|
+
|
|
37
|
+
非法输入抛 ``ValueError``(空串 / 非 http(s) / 解析失败)。
|
|
38
|
+
"""
|
|
39
|
+
raw = (url or "").strip()
|
|
40
|
+
if not raw:
|
|
41
|
+
raise ValueError("BASE URL 不能为空")
|
|
42
|
+
if "://" not in raw:
|
|
43
|
+
raw = "https://" + raw
|
|
44
|
+
try:
|
|
45
|
+
parts = urlsplit(raw)
|
|
46
|
+
except Exception as e: # pragma: no cover - urlsplit 极少抛
|
|
47
|
+
raise ValueError(f"BASE URL 解析失败: {e}")
|
|
48
|
+
if parts.scheme.lower() not in ("http", "https"):
|
|
49
|
+
raise ValueError("BASE URL 必须以 http:// 或 https:// 开头")
|
|
50
|
+
if not parts.netloc:
|
|
51
|
+
raise ValueError("BASE URL 缺少主机名(例:https://api.deepseek.com/v1)")
|
|
52
|
+
path = (parts.path or "").rstrip("/")
|
|
53
|
+
if not path:
|
|
54
|
+
path = "/v1"
|
|
55
|
+
return urlunsplit((parts.scheme.lower(), parts.netloc, path, parts.query, ""))
|
|
56
|
+
|
|
57
|
+
|
|
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
|
+
_THINK_RE = _re.compile("<" + "think" + ">.*?</" + "think" + ">", _re.DOTALL)
|
|
65
|
+
# 部分模型(DeepSeek-R1 早期 / minimax 变体)用 <reasoning> / <reflection> 标签
|
|
66
|
+
# 一并兼容,展示给用户时统一清掉。
|
|
67
|
+
_EXTRA_THINK_RE = _re.compile(r"<(reasoning|reflection|reflexion)>.*?</\1>", _re.DOTALL)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _strip_think(s: str) -> str:
|
|
71
|
+
"""One-shot:整段(非流式)剥离 think 块 / reasoning 块等。"""
|
|
72
|
+
if not s:
|
|
73
|
+
return s
|
|
74
|
+
s = _THINK_RE.sub("", s)
|
|
75
|
+
s = _EXTRA_THINK_RE.sub("", s)
|
|
76
|
+
return s.lstrip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _ThinkStripper:
|
|
80
|
+
"""Stateful:逐 chunk 喂入,返回**可以 yield 给用户**的部分;跨 chunk 状态保留,
|
|
81
|
+
直到 think 块完整闭合,中间内容丢弃。
|
|
82
|
+
|
|
83
|
+
为什么 stateful:模型流式可能在 chunk 边界切碎 think 标签(比如把 OPEN_TAG
|
|
84
|
+
切到下个 chunk),若不在 LLM 咽喉处做跨 chunk 跟踪,部分 think 内容会泄露到
|
|
85
|
+
前端 / 落库记忆 / SSE 流量。"""
|
|
86
|
+
|
|
87
|
+
_OPEN_TAG = "<think>"
|
|
88
|
+
_CLOSE_TAG = "</think>"
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self._in_think = False
|
|
92
|
+
self._buf = ""
|
|
93
|
+
|
|
94
|
+
def feed(self, chunk: str) -> str:
|
|
95
|
+
"""输入新 chunk,返回可 yield 部分(已剥离 think 块)。"""
|
|
96
|
+
return self.feed_events(chunk)[0]
|
|
97
|
+
|
|
98
|
+
def feed_events(self, chunk: str) -> tuple[str, str]:
|
|
99
|
+
"""输入新 chunk,返回 (可见正文, 推理文本)。
|
|
100
|
+
|
|
101
|
+
可见正文与 `feed` 逐字一致(think 块一律不进正文);推理文本是刚闭合的
|
|
102
|
+
think 块内容,由上层作为独立 SSE `thinking` 事件送前端折叠展示——**正文仍净化**,
|
|
103
|
+
思考不再被丢弃(此前整段淘汰,用户只看到结论)。
|
|
104
|
+
"""
|
|
105
|
+
if not chunk:
|
|
106
|
+
return "", ""
|
|
107
|
+
out: list[str] = []
|
|
108
|
+
think: list[str] = []
|
|
109
|
+
text = self._buf + chunk
|
|
110
|
+
self._buf = ""
|
|
111
|
+
i = 0
|
|
112
|
+
n = len(text)
|
|
113
|
+
while i < n:
|
|
114
|
+
if self._in_think:
|
|
115
|
+
end = text.find(self._CLOSE_TAG, i)
|
|
116
|
+
if end == -1:
|
|
117
|
+
# 没遇到结束标签,剩余暂存,等下个 chunk 续上
|
|
118
|
+
self._buf = text[i:]
|
|
119
|
+
return "".join(out), "".join(think)
|
|
120
|
+
think.append(text[i:end])
|
|
121
|
+
i = end + len(self._CLOSE_TAG)
|
|
122
|
+
self._in_think = False
|
|
123
|
+
continue
|
|
124
|
+
else:
|
|
125
|
+
start = text.find(self._OPEN_TAG, i)
|
|
126
|
+
if start == -1:
|
|
127
|
+
# 当前 chunk 没有 <think> 开始标签。
|
|
128
|
+
# 但末尾可能正好切成 "<" / "<th" / "<thi" ... —— 不能直接
|
|
129
|
+
# 整段 yield,否则下个 chunk 拼成 "<think>..." 时已经晚一步。
|
|
130
|
+
# 策略:把最后一个 '<' 之前的安全段 yield,'<' 及其后暂存到 buf。
|
|
131
|
+
last_lt = text.rfind("<", i)
|
|
132
|
+
if last_lt == -1:
|
|
133
|
+
out.append(text[i:])
|
|
134
|
+
return "".join(out), "".join(think)
|
|
135
|
+
out.append(text[i:last_lt])
|
|
136
|
+
self._buf = text[last_lt:]
|
|
137
|
+
return "".join(out), "".join(think)
|
|
138
|
+
out.append(text[i:start])
|
|
139
|
+
i = start + len(self._OPEN_TAG)
|
|
140
|
+
self._in_think = True
|
|
141
|
+
return "".join(out), "".join(think)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# 各 provider 的默认模型(可被环境变量覆盖)
|
|
145
|
+
DEFAULT_MODELS = {
|
|
146
|
+
"anthropic": "claude-sonnet-4-20250514",
|
|
147
|
+
"openai": "gpt-4o",
|
|
148
|
+
"deepseek": "deepseek-chat",
|
|
149
|
+
"google": "gemini-1.5-pro",
|
|
150
|
+
"qwen": "qwen-plus",
|
|
151
|
+
"local": "llama3",
|
|
152
|
+
"minimax": "MiniMax-Text-01",
|
|
153
|
+
"generic": "", # 通用 openai-compatible:必须显式给 model(.env: FOXCLAW_MODEL 或 GENERIC_LLM_MODEL)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# 分层模型:fast(便宜快,分诊/简单)/ smart(强,复杂/分析)。未列的 provider fast=smart=默认。
|
|
157
|
+
TIER_MODELS = {
|
|
158
|
+
"anthropic": {"fast": "claude-haiku-4-20250514", "smart": DEFAULT_MODELS["anthropic"]},
|
|
159
|
+
"deepseek": {"fast": "deepseek-chat", "smart": "deepseek-reasoner"},
|
|
160
|
+
"openai": {"fast": "gpt-4o-mini", "smart": "gpt-4o"},
|
|
161
|
+
"qwen": {"fast": "qwen-turbo", "smart": "qwen-plus"},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# 任务复杂度触发 smart 档的词
|
|
165
|
+
_SMART_HINTS = _re.compile(
|
|
166
|
+
r"分析|规划|报告|方案|评估|深度|论证|拆解|设计|策略|analyz|plan|report|design|strateg|reason")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _route_tier(agent_id: str, messages: list) -> str:
|
|
170
|
+
"""任务→模型档:supervisor 分诊→fast;含复杂词或长任务→smart;否则 fast。"""
|
|
171
|
+
default = os.environ.get("FOXCLAW_ROUTE_DEFAULT_TIER", "smart").strip().lower()
|
|
172
|
+
if agent_id == "supervisor":
|
|
173
|
+
return "fast"
|
|
174
|
+
text = ""
|
|
175
|
+
for m in reversed(messages or []):
|
|
176
|
+
if m.get("role") == "user":
|
|
177
|
+
text = str(m.get("content") or ""); break
|
|
178
|
+
if _SMART_HINTS.search(text) or len(text) > 200:
|
|
179
|
+
return "smart"
|
|
180
|
+
return "fast" if text else (default if default in ("fast", "smart") else "smart")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _tier_model(provider: str, tier: str) -> str:
|
|
184
|
+
t = TIER_MODELS.get(provider)
|
|
185
|
+
if t and t.get(tier):
|
|
186
|
+
return t[tier]
|
|
187
|
+
return DEFAULT_MODELS.get(provider, "")
|
|
188
|
+
|
|
189
|
+
# provider → 所需环境变量
|
|
190
|
+
PROVIDER_ENV = {
|
|
191
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
192
|
+
"openai": "OPENAI_API_KEY",
|
|
193
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
194
|
+
"google": "GOOGLE_API_KEY",
|
|
195
|
+
"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
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _chunk_text(text, size: int = 8):
|
|
203
|
+
"""把文本切成小块逐个 yield(stub/降级时模拟流式,按字符定长,兼容中英)。"""
|
|
204
|
+
text = str(text or "")
|
|
205
|
+
for i in range(0, len(text), size):
|
|
206
|
+
yield text[i:i + size]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── 重试退避(M6)────────────────────────────────────────────────────
|
|
210
|
+
# 病根:此前 fallback 链只有「换 provider」,无退避、无抖动 —— 循环内无任何 sleep,
|
|
211
|
+
# 冷却固定 20s。多实例同时宕机时会同步解冻(惊群)并紧密重试,加剧上游压力。
|
|
212
|
+
# 下面用指数退避 + **全抖动**(full jitter,sleep=random(0,min(cap,base*2^n))):
|
|
213
|
+
# 上限保证不会无限等待,抖动保证多实例不会同步重试。
|
|
214
|
+
import random as _rnd # noqa: E402 (供下方 _backoff_delay 与冷却抖动使用)
|
|
215
|
+
from . import settings
|
|
216
|
+
|
|
217
|
+
# 冷却基准秒(乘抖动系数后使用)。可经 FOXCLAW_LLM_COOLDOWN 覆盖,线上调参不必改代码。
|
|
218
|
+
_COOLDOWN_BASE = float(settings.get_float("FOXCLAW_LLM_COOLDOWN"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 8.0) -> float:
|
|
222
|
+
"""第 attempt 次失败后、换下一个 provider 前的等待秒数:指数退避 + 全抖动。
|
|
223
|
+
|
|
224
|
+
attempt 从 0 起计。返回 [0, min(cap, base*2^attempt)] 上的均匀随机值。
|
|
225
|
+
"""
|
|
226
|
+
return _rnd.uniform(0.0, min(cap, base * (2 ** max(0, attempt))))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ModelRouter:
|
|
230
|
+
"""统一模型路由。Agent 只认 router.complete(),不关心背后是哪家模型。"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, provider: str = None, model: str = None):
|
|
233
|
+
# 默认 provider:按环境变量自动探测第一个可用的
|
|
234
|
+
self.provider = provider or self._auto_detect()
|
|
235
|
+
# 【P2·单 LLM 入口】FOXCLAW_MODEL 已弃用,改读 LLM_MODEL(空值时回退默认模型)
|
|
236
|
+
_env_model = os.environ.get("LLM_MODEL", "").strip()
|
|
237
|
+
self._locked_model = model or _env_model or "" # 显式锁定 → 不分档(向后兼容)
|
|
238
|
+
self.model = self._locked_model or DEFAULT_MODELS.get(self.provider, "")
|
|
239
|
+
self._client = None
|
|
240
|
+
self._clients = {} # provider -> client(fallback 懒建缓存)
|
|
241
|
+
self._client_lock = threading.Lock()
|
|
242
|
+
self._cooldown = {} # provider -> 冷却截止 monotonic(断路器:跳过刚宕机的家)
|
|
243
|
+
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 §二「真相优先」对齐)。
|
|
251
|
+
self.degraded: bool = False
|
|
252
|
+
self.degraded_reason: str = ""
|
|
253
|
+
# per-call 降级标记(单次请求生命周期内消费,不跨调用泄漏)
|
|
254
|
+
self._call_degraded: bool = False
|
|
255
|
+
# 前端 LLM 管理选模型(写 .current_model → mtime 缓存 lazy reload,无需重启)
|
|
256
|
+
self._selection_mtime: float = 0.0
|
|
257
|
+
self._init_client()
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _auto_detect() -> str:
|
|
261
|
+
"""选择 provider:优先 LLM_PROVIDER 显式指定,否则按优先级探测已配 key 的。"""
|
|
262
|
+
# 1. 显式指定(LLM_PROVIDER=deepseek 即可强制用 DeepSeek;兼容旧名 FOXCLAW_PROVIDER)
|
|
263
|
+
forced = (os.environ.get("LLM_PROVIDER", "")
|
|
264
|
+
or os.environ.get("FOXCLAW_PROVIDER", "")).strip().lower()
|
|
265
|
+
if forced and forced in PROVIDER_ENV:
|
|
266
|
+
return forced
|
|
267
|
+
# 2. 自动探测:按优先级找第一个配了 key 的
|
|
268
|
+
for p in ["anthropic", "openai", "deepseek", "google", "qwen", "local", "minimax", "generic"]:
|
|
269
|
+
if os.environ.get(PROVIDER_ENV[p]):
|
|
270
|
+
return p
|
|
271
|
+
return "stub"
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def active(self) -> str:
|
|
275
|
+
return self.provider
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def is_stub(self) -> bool:
|
|
279
|
+
return self.provider == "stub" or self._client is None
|
|
280
|
+
|
|
281
|
+
def _build_client(self, p: str, model: str = None):
|
|
282
|
+
"""构造某 provider 的客户端(供 active 初始化 + fallback 懒建复用)。
|
|
283
|
+
返回 (client_or_None, reason)。SDK 缺/无 key → (None, 原因)。"""
|
|
284
|
+
try:
|
|
285
|
+
if p == "anthropic":
|
|
286
|
+
import anthropic
|
|
287
|
+
key = os.environ.get("ANTHROPIC_API_KEY")
|
|
288
|
+
return (anthropic.Anthropic(api_key=key, timeout=30.0) if key else None,
|
|
289
|
+
"" if key else "未配置 ANTHROPIC_API_KEY")
|
|
290
|
+
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
|
+
try:
|
|
296
|
+
from openai import OpenAI
|
|
297
|
+
except ImportError:
|
|
298
|
+
return None, "需安装 openai 库(pip install openai)"
|
|
299
|
+
extra = {} # default_headers(双 header 鉴权用,例 MiniMax-Group-Id)
|
|
300
|
+
if p == "openai":
|
|
301
|
+
key = os.environ.get("OPENAI_API_KEY"); base = None
|
|
302
|
+
elif p == "deepseek":
|
|
303
|
+
key = os.environ.get("DEEPSEEK_API_KEY"); base = "https://api.deepseek.com/v1"
|
|
304
|
+
elif p == "qwen":
|
|
305
|
+
key = os.environ.get("DASHSCOPE_API_KEY")
|
|
306
|
+
base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
307
|
+
elif p == "local":
|
|
308
|
+
key = os.environ.get("LOCAL_LLM_KEY", "local")
|
|
309
|
+
base = os.environ.get("LOCAL_LLM_BASE", "http://localhost:11434/v1")
|
|
310
|
+
elif p == "minimax":
|
|
311
|
+
# 官方域名,可用 MINIMAX_BASE_URL 自定义代理;group_id 可选(无则不传 header)
|
|
312
|
+
key = os.environ.get("MINIMAX_API_KEY")
|
|
313
|
+
base = os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
|
|
314
|
+
group_id = os.environ.get("MINIMAX_GROUP_ID", "").strip()
|
|
315
|
+
if group_id:
|
|
316
|
+
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 鉴权)
|
|
321
|
+
key = os.environ.get("LLM_API_KEY") or os.environ.get("GENERIC_LLM_API_KEY")
|
|
322
|
+
base = os.environ.get("LLM_BASE_URL") or os.environ.get("GENERIC_LLM_BASE_URL")
|
|
323
|
+
raw = os.environ.get("LLM_EXTRA_HEADERS", "").strip() \
|
|
324
|
+
or os.environ.get("GENERIC_LLM_EXTRA_HEADERS", "").strip()
|
|
325
|
+
if raw:
|
|
326
|
+
import json as _json
|
|
327
|
+
try:
|
|
328
|
+
parsed = _json.loads(raw)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
return None, f"GENERIC_LLM_EXTRA_HEADERS 不是合法 JSON: {e}"
|
|
331
|
+
if not isinstance(parsed, dict):
|
|
332
|
+
return None, "GENERIC_LLM_EXTRA_HEADERS 必须是 JSON object(例: {\"X-Foo\": \"bar\"})"
|
|
333
|
+
extra = {str(k): str(v) for k, v in parsed.items()}
|
|
334
|
+
if not (key and base):
|
|
335
|
+
return None, f"未配置 {p} 的 API Key 或 BASE_URL(检查 .env)"
|
|
336
|
+
return (OpenAI(api_key=key, base_url=base, timeout=30.0, max_retries=2,
|
|
337
|
+
default_headers=extra) if extra else
|
|
338
|
+
OpenAI(api_key=key, base_url=base, timeout=30.0, max_retries=2),
|
|
339
|
+
"")
|
|
340
|
+
elif p == "google":
|
|
341
|
+
import google.generativeai as genai
|
|
342
|
+
key = os.environ.get("GOOGLE_API_KEY")
|
|
343
|
+
if key:
|
|
344
|
+
genai.configure(api_key=key)
|
|
345
|
+
return genai.GenerativeModel(model or self.model or _tier_model(p, "smart")), ""
|
|
346
|
+
return None, "未配置 GOOGLE_API_KEY"
|
|
347
|
+
return None, f"未知 provider: {p}"
|
|
348
|
+
except Exception as e:
|
|
349
|
+
return None, f"初始化失败: {e}"
|
|
350
|
+
|
|
351
|
+
def _init_client(self):
|
|
352
|
+
"""初始化 active provider 的客户端(降级 stub)。"""
|
|
353
|
+
self._client, self.fallback_reason = self._build_client(self.provider)
|
|
354
|
+
with self._client_lock:
|
|
355
|
+
self._clients[self.provider] = self._client
|
|
356
|
+
|
|
357
|
+
def _get_client(self, p: str):
|
|
358
|
+
"""取某 provider 客户端(缓存;fallback 时懒建)。"""
|
|
359
|
+
if p == self.provider:
|
|
360
|
+
return self._client
|
|
361
|
+
with self._client_lock:
|
|
362
|
+
if p in self._clients:
|
|
363
|
+
return self._clients[p]
|
|
364
|
+
c, _ = self._build_client(p)
|
|
365
|
+
self._clients[p] = c
|
|
366
|
+
return c
|
|
367
|
+
|
|
368
|
+
def _fallback_order(self) -> list:
|
|
369
|
+
"""调用顺序:active 优先,再按优先级补上其它已配 key 的 provider。"""
|
|
370
|
+
order = [self.provider]
|
|
371
|
+
for p in ["anthropic", "openai", "deepseek", "google", "qwen", "local", "minimax", "generic"]:
|
|
372
|
+
if p != self.provider and os.environ.get(PROVIDER_ENV.get(p, ""), ""):
|
|
373
|
+
order.append(p)
|
|
374
|
+
return order
|
|
375
|
+
|
|
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
|
+
|
|
380
|
+
def _selection_path(self) -> "Path":
|
|
381
|
+
return Path(os.environ.get("FOXCLAW_DATA_DIR", "./foxclaw_data")) / ".current_model"
|
|
382
|
+
|
|
383
|
+
def _custom_models_path(self) -> "Path":
|
|
384
|
+
return Path(os.environ.get("FOXCLAW_DATA_DIR", "./foxclaw_data")) / "custom_models.json"
|
|
385
|
+
|
|
386
|
+
def _build_custom_client(self, name: str):
|
|
387
|
+
"""从 ``custom_models.json`` 读 + 构造 OpenAI client。
|
|
388
|
+
返回 ``(client_or_None, model, error_str)``。"""
|
|
389
|
+
from . import secrets as _secrets
|
|
390
|
+
p = self._custom_models_path()
|
|
391
|
+
if not p.exists():
|
|
392
|
+
return None, "", "custom_models.json 不存在"
|
|
393
|
+
try:
|
|
394
|
+
models = json.loads(p.read_text(encoding="utf-8"))
|
|
395
|
+
except Exception as e:
|
|
396
|
+
return None, "", f"读 custom_models.json 失败: {e}"
|
|
397
|
+
match = next((m for m in models if m.get("name") == name), None)
|
|
398
|
+
if not match:
|
|
399
|
+
return None, "", f"未找到自定义模型: {name}"
|
|
400
|
+
# key:OS 凭据库优先,回退 base64(旧记录)/ 更早的明文字段
|
|
401
|
+
key = _secrets.load(name, match.get("api_key_obf", "") or "")
|
|
402
|
+
if not key:
|
|
403
|
+
key = match.get("api_key", "") or ""
|
|
404
|
+
if not key:
|
|
405
|
+
return None, "", "API key 为空"
|
|
406
|
+
# 老记录可能存成不带 /v1 的形式 → 构造时统一规范化(与 llm_admin 新增/编辑同源)
|
|
407
|
+
try:
|
|
408
|
+
base_url = normalize_base_url(match.get("base_url", ""))
|
|
409
|
+
except ValueError as e:
|
|
410
|
+
return None, "", f"BASE URL 非法: {e}"
|
|
411
|
+
try:
|
|
412
|
+
from openai import OpenAI
|
|
413
|
+
client = OpenAI(
|
|
414
|
+
api_key=key,
|
|
415
|
+
base_url=base_url,
|
|
416
|
+
timeout=30.0,
|
|
417
|
+
max_retries=2,
|
|
418
|
+
default_headers=match.get("extra_headers") or {},
|
|
419
|
+
)
|
|
420
|
+
except Exception as e:
|
|
421
|
+
return None, "", f"构造 client 失败: {e}"
|
|
422
|
+
return client, match.get("model", ""), ""
|
|
423
|
+
|
|
424
|
+
def _maybe_reload_selection(self):
|
|
425
|
+
"""检查 ``.current_model`` 文件;mtime 变更即重建 active client。
|
|
426
|
+
|
|
427
|
+
副作用:
|
|
428
|
+
- 文件不存在 → 退回 ``_auto_detect()`` 探测的 provider(与 .env 一致)。
|
|
429
|
+
- ``source=preset`` → 切到对应 provider(``_init_client`` 重建)。
|
|
430
|
+
- ``source=custom`` → 构造 custom OpenAI client,挂到 ``self._clients["custom"]``,
|
|
431
|
+
同步 ``self.provider = "custom"`` / ``self.model``。"""
|
|
432
|
+
p = self._selection_path()
|
|
433
|
+
try:
|
|
434
|
+
mtime = p.stat().st_mtime if p.exists() else 0.0
|
|
435
|
+
except Exception:
|
|
436
|
+
mtime = 0.0
|
|
437
|
+
if mtime == self._selection_mtime:
|
|
438
|
+
return # 没变 → 零开销
|
|
439
|
+
self._selection_mtime = mtime
|
|
440
|
+
if not p.exists():
|
|
441
|
+
# 删了 .current_model → 退回 .env 探测
|
|
442
|
+
env_prov = self._auto_detect()
|
|
443
|
+
if env_prov != self.provider:
|
|
444
|
+
self.provider = env_prov
|
|
445
|
+
self._reset_locked_for_provider()
|
|
446
|
+
self._init_client()
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
450
|
+
except Exception:
|
|
451
|
+
return
|
|
452
|
+
if not isinstance(d, dict):
|
|
453
|
+
return
|
|
454
|
+
src = d.get("source")
|
|
455
|
+
if src == "preset" and d.get("provider"):
|
|
456
|
+
new_prov = d["provider"]
|
|
457
|
+
if new_prov != self.provider:
|
|
458
|
+
self.provider = new_prov
|
|
459
|
+
self._reset_locked_for_provider() # 切 provider:FOXCLAW_MODEL 仅 generic-like 适用
|
|
460
|
+
self._init_client() # 重建 _client
|
|
461
|
+
elif src == "custom":
|
|
462
|
+
name = d.get("name", "")
|
|
463
|
+
client, model, err = self._build_custom_client(name)
|
|
464
|
+
if client is not None:
|
|
465
|
+
self.provider = "custom"
|
|
466
|
+
with self._client_lock:
|
|
467
|
+
self._clients["custom"] = client
|
|
468
|
+
self._client = client # 同步 active
|
|
469
|
+
self.model = model
|
|
470
|
+
self._locked_model = model
|
|
471
|
+
self.fallback_reason = ""
|
|
472
|
+
else:
|
|
473
|
+
# 构造失败:log + 保持旧 provider(不静默吞)
|
|
474
|
+
import sys as _sys
|
|
475
|
+
print(f"[model_router] 自定义模型构造失败: {err}", file=_sys.stderr)
|
|
476
|
+
|
|
477
|
+
def _reset_locked_for_provider(self):
|
|
478
|
+
"""切换 provider 时重置 ``_locked_model`` / ``self.model``。
|
|
479
|
+
|
|
480
|
+
``FOXCLAW_MODEL`` 是在配 generic/minimax/custom 时显式设的,不应污染切到
|
|
481
|
+
anthropic/openai/local 等有固定默认的 provider(否则拿 MiniMax-Text-01 去
|
|
482
|
+
ollama 调 → 404)。"""
|
|
483
|
+
_env = os.environ.get("FOXCLAW_MODEL", "").strip()
|
|
484
|
+
if self.provider in ("generic", "minimax", "custom"):
|
|
485
|
+
# 这些 provider 没固定默认,FOXCLAW_MODEL 适用
|
|
486
|
+
self._locked_model = _env or None
|
|
487
|
+
else:
|
|
488
|
+
# 其他 provider 用自己默认,忽略 FOXCLAW_MODEL 干扰
|
|
489
|
+
self._locked_model = None
|
|
490
|
+
self.model = self._locked_model or DEFAULT_MODELS.get(self.provider, "")
|
|
491
|
+
|
|
492
|
+
def _model_for(self, provider: str, agent_id: str, messages: list) -> str:
|
|
493
|
+
"""按任务/Agent 选该 provider 的模型:锁定则用锁定值;否则按档。active provider 支持
|
|
494
|
+
FOXCLAW_MODEL_FAST/SMART env 覆盖。generic 必须显式给 model(.env 任意一个都行)。"""
|
|
495
|
+
if self._locked_model and provider == self.provider:
|
|
496
|
+
return self._locked_model
|
|
497
|
+
tier = _route_tier(agent_id, messages)
|
|
498
|
+
if provider == self.provider:
|
|
499
|
+
if provider == "generic":
|
|
500
|
+
# generic 没默认 model → 必须 .env 显式给;优先级 FOXCLAW_MODEL > GENERIC_LLM_MODEL
|
|
501
|
+
m = (os.environ.get("FOXCLAW_MODEL", "").strip() or
|
|
502
|
+
os.environ.get("GENERIC_LLM_MODEL", "").strip())
|
|
503
|
+
if m:
|
|
504
|
+
return m
|
|
505
|
+
env = os.environ.get("FOXCLAW_MODEL_FAST" if tier == "fast" else "FOXCLAW_MODEL_SMART", "").strip()
|
|
506
|
+
if env:
|
|
507
|
+
return env
|
|
508
|
+
return _tier_model(provider, tier)
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def _attach_images(provider, messages, images):
|
|
512
|
+
"""把图片附到最后一条 user 消息,按 provider 组装多模态 content。
|
|
513
|
+
anthropic → image/base64 block;openai 系 → image_url(data URI)。
|
|
514
|
+
其它 provider(google/未知)不支持 → 原样返回(纯文本降级)。
|
|
515
|
+
messages 的文本仍供 tier 路由/stub/meter 用,故只在 provider 调用点临时组装,不改原列表。"""
|
|
516
|
+
if not images:
|
|
517
|
+
return messages
|
|
518
|
+
msgs = [dict(m) for m in messages]
|
|
519
|
+
idx = next((i for i in range(len(msgs) - 1, -1, -1)
|
|
520
|
+
if msgs[i].get("role") == "user"), None)
|
|
521
|
+
if idx is None:
|
|
522
|
+
return messages
|
|
523
|
+
text = msgs[idx].get("content")
|
|
524
|
+
if not isinstance(text, str):
|
|
525
|
+
return messages # 已是多模态,不重复组装
|
|
526
|
+
if provider == "anthropic":
|
|
527
|
+
blocks = [{"type": "text", "text": text}]
|
|
528
|
+
for im in images:
|
|
529
|
+
blocks.append({"type": "image", "source": {
|
|
530
|
+
"type": "base64",
|
|
531
|
+
"media_type": im.get("media_type", "image/png"),
|
|
532
|
+
"data": im.get("data", "")}})
|
|
533
|
+
elif provider in ModelRouter._OPENAI_FAMILY:
|
|
534
|
+
blocks = [{"type": "text", "text": text}]
|
|
535
|
+
for im in images:
|
|
536
|
+
mt = im.get("media_type", "image/png"); d = im.get("data", "")
|
|
537
|
+
blocks.append({"type": "image_url",
|
|
538
|
+
"image_url": {"url": f"data:{mt};base64,{d}"}})
|
|
539
|
+
else:
|
|
540
|
+
return messages # google/未知:无视觉通道 → 纯文本
|
|
541
|
+
msgs[idx]["content"] = blocks
|
|
542
|
+
return msgs
|
|
543
|
+
|
|
544
|
+
def _call_provider(self, provider, client, model, system, messages, max_tokens, images=None):
|
|
545
|
+
"""同步调一个 provider,返回 (output, usage);失败抛异常(供 fallback)。"""
|
|
546
|
+
if images:
|
|
547
|
+
messages = self._attach_images(provider, messages, images)
|
|
548
|
+
if provider == "anthropic":
|
|
549
|
+
resp = client.messages.create(model=model, max_tokens=max_tokens,
|
|
550
|
+
system=system, messages=messages)
|
|
551
|
+
u = getattr(resp, "usage", None)
|
|
552
|
+
usage = ({"input_tokens": getattr(u, "input_tokens", None),
|
|
553
|
+
"output_tokens": getattr(u, "output_tokens", None)} if u else None)
|
|
554
|
+
return resp.content[0].text, usage
|
|
555
|
+
elif provider in self._OPENAI_FAMILY:
|
|
556
|
+
msgs = [{"role": "system", "content": system}] + messages
|
|
557
|
+
resp = client.chat.completions.create(model=model, max_tokens=max_tokens, messages=msgs)
|
|
558
|
+
u = getattr(resp, "usage", None)
|
|
559
|
+
usage = ({"input_tokens": getattr(u, "prompt_tokens", None),
|
|
560
|
+
"output_tokens": getattr(u, "completion_tokens", None)} if u else None)
|
|
561
|
+
# 剥离内部推理块(DeepSeek-R1 / MiniMax-Text-01 / minimax-reasoner 等
|
|
562
|
+
# 把 <think>...</think> 嵌进 content,直接落库/转发会污染用户界面与记忆)
|
|
563
|
+
return _strip_think(resp.choices[0].message.content or ""), usage
|
|
564
|
+
elif provider == "google":
|
|
565
|
+
prompt = system + "\n\n" + "\n".join(m["content"] for m in messages)
|
|
566
|
+
return client.generate_content(prompt).text, None
|
|
567
|
+
raise RuntimeError(f"unknown provider {provider}")
|
|
568
|
+
|
|
569
|
+
# ── 统一补全接口 ──────────────────────────────────────
|
|
570
|
+
@staticmethod
|
|
571
|
+
def _weekday_cn(dt) -> str:
|
|
572
|
+
return "一二三四五六日"[dt.weekday()]
|
|
573
|
+
|
|
574
|
+
def _runtime_context(self) -> str:
|
|
575
|
+
"""运行时事实基准 —— 规则化注入到每次 LLM 调用的 system 头部。
|
|
576
|
+
|
|
577
|
+
病根:过去"今天几号 / 你是什么模型"靠散落的关键词表(_model_augment / needs_search)
|
|
578
|
+
临时猜,哪坏改哪、彼此打架。规则化解法:在 LLM **唯一咽喉**(complete/stream)统一提供
|
|
579
|
+
一段权威运行时事实,所有 Agent、每次调用自动带上,不再靠关键词触发。
|
|
580
|
+
模型无关:provider/model 取自当前路由配置,换任何模型都自动跟随,不硬编码。"""
|
|
581
|
+
import datetime as _dt
|
|
582
|
+
now = _dt.datetime.now()
|
|
583
|
+
stamp = f"{now:%Y-%m-%d} 周{self._weekday_cn(now)} {now:%H:%M}"
|
|
584
|
+
model = self.model or DEFAULT_MODELS.get(self.provider, self.provider)
|
|
585
|
+
return (
|
|
586
|
+
"【运行时信息(系统实时提供,为权威事实,高于对话历史中任何过时或矛盾的说法)】\n"
|
|
587
|
+
f"- 当前日期时间:{stamp}(用户本地时区)。回答\"今天/现在/日期/几号/星期几\"一律以此为准。\n"
|
|
588
|
+
f"- 你当前实际由 {self.provider} 的「{model}」模型驱动。被问及自身身份/型号时如实回答这个,"
|
|
589
|
+
"绝不要声称是 GPT / Claude / 文心一言 / 云雀 等其它模型。\n\n"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def _inject_runtime(self, system: str) -> str:
|
|
593
|
+
"""把运行时事实基准前置到 system;幂等(已注入则不重复,防 stream→complete 二次注入)。"""
|
|
594
|
+
s = system or ""
|
|
595
|
+
if s.lstrip().startswith("【运行时信息"):
|
|
596
|
+
return system
|
|
597
|
+
return self._runtime_context() + s
|
|
598
|
+
|
|
599
|
+
def complete(self, system: str, messages: list, max_tokens: int = 2048,
|
|
600
|
+
agent_id: str = "", images: list = None) -> str:
|
|
601
|
+
"""统一文本补全。messages 为 [{role, content}]。images(可选)= [{media_type,data}] 多模态图片。
|
|
602
|
+
|
|
603
|
+
所有 LLM 调用的唯一咽喉——token 计量在此进行(若挂了 meter)。
|
|
604
|
+
优先用 API 返回的真实 usage,拿不到则由 meter 离线估算。
|
|
605
|
+
"""
|
|
606
|
+
self._maybe_reload_selection() # 前端切模型(.current_model)→ mtime 缓存 lazy reload
|
|
607
|
+
system = self._inject_runtime(system) # 规则化:运行时事实基准统一在咽喉注入
|
|
608
|
+
if self.is_stub:
|
|
609
|
+
# stub 不消耗 token,但计入 LLM 调用量(可观测);链路里也要看得见"这次走了 stub"
|
|
610
|
+
try:
|
|
611
|
+
from .metrics import METRICS
|
|
612
|
+
METRICS.record_llm("stub", True, 0.0)
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
from .tracing import span as _span
|
|
616
|
+
with _span("llm.complete", provider="stub"):
|
|
617
|
+
return self._stub(messages)
|
|
618
|
+
import time as _t
|
|
619
|
+
last_err = None
|
|
620
|
+
_now = _t.monotonic()
|
|
621
|
+
for i, provider in enumerate(self._fallback_order()):
|
|
622
|
+
if self._cooldown.get(provider, 0) > _now: # 断路器:跳过刚宕机的家,免每次等超时
|
|
623
|
+
continue
|
|
624
|
+
client = self._get_client(provider)
|
|
625
|
+
if client is None:
|
|
626
|
+
continue
|
|
627
|
+
model = self._model_for(provider, agent_id, messages) # 按任务/Agent 选档
|
|
628
|
+
_t0 = _t.perf_counter()
|
|
629
|
+
try:
|
|
630
|
+
from .tracing import span as _span
|
|
631
|
+
with _span("llm.complete", provider=provider, model=model):
|
|
632
|
+
if images: # 有图 → 传第 7 参数;无图保持 6 参数(兼容 test 里 6 参 monkeypatch)
|
|
633
|
+
output, usage = self._call_provider(provider, client, model,
|
|
634
|
+
system, messages, max_tokens, images)
|
|
635
|
+
else:
|
|
636
|
+
output, usage = self._call_provider(provider, client, model,
|
|
637
|
+
system, messages, max_tokens)
|
|
638
|
+
except Exception as e:
|
|
639
|
+
self._rec_llm(provider, False, (_t.perf_counter() - _t0) * 1000.0)
|
|
640
|
+
# 冷却时长加抖动(D 类修复):原为固定 20.0,多实例同时宕机会**同步解冻**
|
|
641
|
+
# (惊群)。抖动后各实例解冻时间分散,上游有喘息窗口。
|
|
642
|
+
self._cooldown[provider] = _t.monotonic() + (
|
|
643
|
+
_COOLDOWN_BASE * (0.75 + 0.5 * _rnd.random()))
|
|
644
|
+
last_err = e
|
|
645
|
+
# 退避 + 全抖动(M6):换 provider 前让出时间,避免「快速遍历全部 provider
|
|
646
|
+
# 再快速重来」的紧密重试风暴。首次失败不等待(保 fallback 的快速感),
|
|
647
|
+
# 之后随尝试次数递增退避。
|
|
648
|
+
if i > 0:
|
|
649
|
+
_t.sleep(_backoff_delay(i))
|
|
650
|
+
continue # → 试下一个已配 provider(fallback 链)
|
|
651
|
+
self._cooldown.pop(provider, None) # 成功 → 解冻
|
|
652
|
+
self._rec_llm(provider, True, (_t.perf_counter() - _t0) * 1000.0)
|
|
653
|
+
if output and getattr(self, "meter", None) is not None:
|
|
654
|
+
try:
|
|
655
|
+
self.meter.measure_call(
|
|
656
|
+
system=system, messages=messages, output=output,
|
|
657
|
+
provider=provider, model=model, agent_id=agent_id, usage=usage)
|
|
658
|
+
except Exception:
|
|
659
|
+
pass
|
|
660
|
+
if i > 0:
|
|
661
|
+
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
|
+
if not (output or "").strip():
|
|
668
|
+
self._mark_degraded(
|
|
669
|
+
f"provider={provider} model={model} 返回空响应"
|
|
670
|
+
f"(疑似内容过滤或 max_tokens 截断)")
|
|
671
|
+
return ("【模型返回为空】本次未获得有效回复,任务未完成。"
|
|
672
|
+
"可能原因:内容被模型安全策略过滤,或 max_tokens 过小被截断。"
|
|
673
|
+
"建议:调整提问方式或提高 max_tokens 后重试。")
|
|
674
|
+
return output
|
|
675
|
+
# 全部 provider 都失败:显式失败 + 降级标志,**不再拼接 stub 成功文案**。
|
|
676
|
+
# 保留「全部模型失败」这一精确措辞 —— 它是故障语义(而非"未配置模型"的降级),
|
|
677
|
+
# 运维与测试都靠它识别真实故障。
|
|
678
|
+
self._mark_degraded(f"全部 provider 调用失败: {last_err}")
|
|
679
|
+
return (f"【全部模型失败】已尝试 {len(self._fallback_order())} 个 provider 均失败,"
|
|
680
|
+
f"本次任务未完成。最后错误:"
|
|
681
|
+
f"{type(last_err).__name__ if last_err else '未知'}。"
|
|
682
|
+
f"请检查网络与 API Key 配置后重试。")
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def _rec_llm(provider, ok, ms):
|
|
686
|
+
try:
|
|
687
|
+
from .metrics import METRICS
|
|
688
|
+
METRICS.record_llm(provider, ok, ms)
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
|
|
692
|
+
def stream(self, system: str, messages: list, max_tokens: int = 2048,
|
|
693
|
+
agent_id: str = "", stop_event=None, images: list = None,
|
|
694
|
+
on_thinking=None):
|
|
695
|
+
"""流式文本补全:逐块 yield。各 provider 用其流式 API;stub/异常降级分块。
|
|
696
|
+
与 complete() 同为 LLM 咽喉,收尾做 token 计量。images(可选)= 多模态图片。
|
|
697
|
+
stop_event:threading.Event,置位则提前终止(客户端断连时省 token)。
|
|
698
|
+
on_thinking(可选)= 推理增量回调:provider 原生 reasoning(reasoning_content)
|
|
699
|
+
与正文内嵌 <think> 块都从这里出去,作为独立 SSE thinking 事件;正文不受影响。"""
|
|
700
|
+
def _stopped():
|
|
701
|
+
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
|
+
self._maybe_reload_selection()
|
|
706
|
+
system = self._inject_runtime(system) # 规则化:与 complete 同一咽喉注入运行时事实基准
|
|
707
|
+
if self.is_stub:
|
|
708
|
+
try:
|
|
709
|
+
from .metrics import METRICS
|
|
710
|
+
METRICS.record_llm("stub", True, 0.0)
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
try:
|
|
714
|
+
from .tracing import current_trace
|
|
715
|
+
tr = current_trace()
|
|
716
|
+
if tr is not None:
|
|
717
|
+
tr.add_span({"name": "llm.stream", "ok": True, "ms": 0.0,
|
|
718
|
+
"meta": {"provider": "stub"}})
|
|
719
|
+
except Exception:
|
|
720
|
+
pass
|
|
721
|
+
for w in _chunk_text(self._stub(messages)):
|
|
722
|
+
if _stopped():
|
|
723
|
+
return
|
|
724
|
+
yield w
|
|
725
|
+
return
|
|
726
|
+
parts = []
|
|
727
|
+
import time as _t
|
|
728
|
+
_t0 = _t.perf_counter()
|
|
729
|
+
model = self._model_for(self.provider, agent_id, messages) # 按任务/Agent 选档
|
|
730
|
+
|
|
731
|
+
def _rec(ok):
|
|
732
|
+
ms = (_t.perf_counter() - _t0) * 1000.0
|
|
733
|
+
try:
|
|
734
|
+
from .metrics import METRICS
|
|
735
|
+
METRICS.record_llm(self.provider, ok, ms)
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
738
|
+
try: # 链路追踪:流式是 chat 主路径,不能漏(span 归属发起请求的 trace)
|
|
739
|
+
from .tracing import current_trace
|
|
740
|
+
tr = current_trace()
|
|
741
|
+
if tr is not None:
|
|
742
|
+
tr.add_span({"name": "llm.stream", "ok": ok, "ms": round(ms, 2),
|
|
743
|
+
"meta": {"provider": self.provider, "model": model}})
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
mm = self._attach_images(self.provider, messages, images) if images else messages
|
|
747
|
+
try:
|
|
748
|
+
if self.provider == "anthropic":
|
|
749
|
+
with self._client.messages.stream(
|
|
750
|
+
model=model, max_tokens=max_tokens,
|
|
751
|
+
system=system, messages=mm) as s:
|
|
752
|
+
for t in s.text_stream:
|
|
753
|
+
if _stopped():
|
|
754
|
+
return
|
|
755
|
+
if t:
|
|
756
|
+
parts.append(t); yield t
|
|
757
|
+
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
|
+
msgs = [{"role": "system", "content": system}] + mm
|
|
765
|
+
st = self._client.chat.completions.create(
|
|
766
|
+
model=model, max_tokens=max_tokens, messages=msgs, stream=True)
|
|
767
|
+
stripper = _ThinkStripper()
|
|
768
|
+
for c in st:
|
|
769
|
+
if _stopped():
|
|
770
|
+
st.close() if hasattr(st, "close") else None
|
|
771
|
+
return
|
|
772
|
+
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
|
+
if c.choices and on_thinking is not None:
|
|
777
|
+
_delta = c.choices[0].delta
|
|
778
|
+
_rt = (getattr(_delta, "reasoning_content", None)
|
|
779
|
+
or getattr(_delta, "reasoning", None))
|
|
780
|
+
if _rt:
|
|
781
|
+
on_thinking(_rt)
|
|
782
|
+
if not d:
|
|
783
|
+
continue
|
|
784
|
+
parts.append(d)
|
|
785
|
+
cleaned, _think_txt = stripper.feed_events(d)
|
|
786
|
+
if _think_txt and on_thinking is not None:
|
|
787
|
+
on_thinking(_think_txt) # 内嵌 <think> 块 → 推理事件,不进正文
|
|
788
|
+
if cleaned:
|
|
789
|
+
yield cleaned
|
|
790
|
+
elif self.provider == "google":
|
|
791
|
+
prompt = system + "\n\n" + "\n".join(m["content"] for m in messages)
|
|
792
|
+
for c in self._client.generate_content(prompt, stream=True):
|
|
793
|
+
if _stopped():
|
|
794
|
+
return
|
|
795
|
+
if getattr(c, "text", ""):
|
|
796
|
+
parts.append(c.text); yield c.text
|
|
797
|
+
else:
|
|
798
|
+
out = self.complete(system, messages, max_tokens, agent_id)
|
|
799
|
+
parts.append(out); yield out
|
|
800
|
+
except Exception as e:
|
|
801
|
+
_rec(False)
|
|
802
|
+
# 脱敏(M1 A 类修复):不把原始异常对象吐给用户 —— 它可能含内部端点、
|
|
803
|
+
# 文件路径或请求细节。只给异常类型 + 降级语义,完整原因进 tracing。
|
|
804
|
+
self._mark_degraded(f"stream 失败: {type(e).__name__}")
|
|
805
|
+
yield f"【模型流式中断】{type(e).__name__},本次未获得完整回复。\n"
|
|
806
|
+
for w in _chunk_text(self._stub(messages)):
|
|
807
|
+
yield w
|
|
808
|
+
return
|
|
809
|
+
_rec(True)
|
|
810
|
+
output = "".join(parts)
|
|
811
|
+
if output and getattr(self, "meter", None) is not None:
|
|
812
|
+
try:
|
|
813
|
+
self.meter.measure_call(
|
|
814
|
+
system=system, messages=messages, output=output,
|
|
815
|
+
provider=self.provider, model=self.model,
|
|
816
|
+
agent_id=agent_id, usage=None)
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
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
|
+
_OPENAI_FAMILY = ("openai", "deepseek", "qwen", "local", "generic", "minimax", "custom")
|
|
830
|
+
_TOOL_PROVIDERS = _OPENAI_FAMILY + ("anthropic",)
|
|
831
|
+
|
|
832
|
+
def supports_tools(self) -> bool:
|
|
833
|
+
return (not self.is_stub) and self.provider in self._TOOL_PROVIDERS
|
|
834
|
+
|
|
835
|
+
@staticmethod
|
|
836
|
+
def _to_anthropic_tools(tools: list) -> list:
|
|
837
|
+
"""OpenAI tools 格式 → Anthropic tools 格式。
|
|
838
|
+
|
|
839
|
+
OpenAI: {type: "function", function: {name, description, parameters}}
|
|
840
|
+
Anthropic:{name, description, input_schema}
|
|
841
|
+
"""
|
|
842
|
+
out = []
|
|
843
|
+
for t in tools or []:
|
|
844
|
+
if not isinstance(t, dict):
|
|
845
|
+
continue
|
|
846
|
+
fn = t.get("function") if t.get("type") == "function" else t
|
|
847
|
+
if not isinstance(fn, dict):
|
|
848
|
+
continue
|
|
849
|
+
name = fn.get("name")
|
|
850
|
+
if not name:
|
|
851
|
+
continue
|
|
852
|
+
out.append({
|
|
853
|
+
"name": name,
|
|
854
|
+
"description": fn.get("description", "") or "",
|
|
855
|
+
"input_schema": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
856
|
+
})
|
|
857
|
+
return out
|
|
858
|
+
|
|
859
|
+
@staticmethod
|
|
860
|
+
def _to_anthropic_tool_choice(tool_choice):
|
|
861
|
+
"""OpenAI tool_choice → Anthropic tool_choice。
|
|
862
|
+
auto→{type:auto};none→{type:none};required→{type:any};
|
|
863
|
+
{type:function,function:{name}}→{type:tool,name}。"""
|
|
864
|
+
if tool_choice in (None, "auto"):
|
|
865
|
+
return {"type": "auto"}
|
|
866
|
+
if tool_choice == "none":
|
|
867
|
+
return {"type": "none"}
|
|
868
|
+
if tool_choice == "required":
|
|
869
|
+
return {"type": "any"}
|
|
870
|
+
if isinstance(tool_choice, dict):
|
|
871
|
+
fn = tool_choice.get("function") or {}
|
|
872
|
+
name = fn.get("name")
|
|
873
|
+
if name:
|
|
874
|
+
return {"type": "tool", "name": name}
|
|
875
|
+
return {"type": "auto"}
|
|
876
|
+
|
|
877
|
+
def call_tools(self, system: str, messages: list, tools: list,
|
|
878
|
+
tool_choice: str = "auto", max_tokens: int = 1024,
|
|
879
|
+
agent_id: str = "") -> dict:
|
|
880
|
+
"""带工具的一次(非流式)LLM 调用。返回归一化结果:
|
|
881
|
+
{supported: bool, text: str,
|
|
882
|
+
tool_calls: [{id, name, args(dict)}],
|
|
883
|
+
assistant_msg: dict} # provider 原生的 assistant 消息,供多轮回灌时追加
|
|
884
|
+
不支持 tools 的 provider → {supported: False,...},上层走 fallback。
|
|
885
|
+
与 complete/stream 同一咽喉:注入运行时事实基准(否则模型会把日期猜成训练年份)。"""
|
|
886
|
+
system = self._inject_runtime(system)
|
|
887
|
+
if not self.supports_tools():
|
|
888
|
+
return {"supported": False, "text": "", "tool_calls": [], "assistant_msg": None}
|
|
889
|
+
model = self._model_for(self.provider, agent_id, messages)
|
|
890
|
+
import time as _t
|
|
891
|
+
_t0 = _t.perf_counter()
|
|
892
|
+
try:
|
|
893
|
+
from .tracing import span as _span
|
|
894
|
+
with _span("llm.tools", provider=self.provider, model=model):
|
|
895
|
+
if self.provider == "anthropic":
|
|
896
|
+
text, calls, assistant_msg = self._call_anthropic_tools(
|
|
897
|
+
model, system, messages, tools, tool_choice, max_tokens)
|
|
898
|
+
else:
|
|
899
|
+
text, calls, assistant_msg = self._call_openai_tools(
|
|
900
|
+
model, system, messages, tools, tool_choice, max_tokens)
|
|
901
|
+
except Exception as e:
|
|
902
|
+
self._rec_llm(self.provider, False, (_t.perf_counter() - _t0) * 1000.0)
|
|
903
|
+
# 工具调用失败(端点不支持 tools 等)→ 当作不支持,让上层降级
|
|
904
|
+
return {"supported": False, "text": "", "tool_calls": [],
|
|
905
|
+
"assistant_msg": None, "error": str(e)}
|
|
906
|
+
self._rec_llm(self.provider, True, (_t.perf_counter() - _t0) * 1000.0)
|
|
907
|
+
# 计量(工具决策轮也算调用)
|
|
908
|
+
if getattr(self, "meter", None) is not None:
|
|
909
|
+
try:
|
|
910
|
+
self.meter.measure_call(system=system, messages=messages,
|
|
911
|
+
output=text, provider=self.provider,
|
|
912
|
+
model=model, agent_id=agent_id, usage=None)
|
|
913
|
+
except Exception:
|
|
914
|
+
pass
|
|
915
|
+
return {"supported": True, "text": text,
|
|
916
|
+
"tool_calls": calls, "assistant_msg": assistant_msg}
|
|
917
|
+
|
|
918
|
+
def _call_openai_tools(self, model, system, messages, tools, tool_choice, max_tokens):
|
|
919
|
+
"""openai 家族:chat.completions.create + tools,解析 tool_calls 为归一化结构。
|
|
920
|
+
返回 (text, calls, assistant_msg)。"""
|
|
921
|
+
msgs = [{"role": "system", "content": system}] + list(messages)
|
|
922
|
+
resp = self._client.chat.completions.create(
|
|
923
|
+
model=model, max_tokens=max_tokens, messages=msgs,
|
|
924
|
+
tools=tools, tool_choice=tool_choice)
|
|
925
|
+
m = resp.choices[0].message
|
|
926
|
+
calls = []
|
|
927
|
+
raw_tcs = []
|
|
928
|
+
for tc in (getattr(m, "tool_calls", None) or []):
|
|
929
|
+
import json as _json
|
|
930
|
+
raw_args = tc.function.arguments or "{}"
|
|
931
|
+
parse_failed = False
|
|
932
|
+
try:
|
|
933
|
+
args = _json.loads(raw_args)
|
|
934
|
+
if not isinstance(args, dict):
|
|
935
|
+
# 模型偶发返回数组/标量(JSON 合法但结构不符)→ 同样按解析失败处理
|
|
936
|
+
args, parse_failed = {}, True
|
|
937
|
+
except Exception:
|
|
938
|
+
args, parse_failed = {}, True
|
|
939
|
+
if parse_failed:
|
|
940
|
+
# 静默 {} 会让模型以为工具已执行(实际参数全丢)→ 错误必须可见。
|
|
941
|
+
# 此处**保留空 args 继续分发**:由 base._dispatch_tool 的参数校验闸
|
|
942
|
+
# 把错误回注给模型,下一轮即可自修正;这里额外记 span,便于事后归因
|
|
943
|
+
# (此前完全无痕迹,模型错了都不知道)。
|
|
944
|
+
try:
|
|
945
|
+
from .tracing import span as _span
|
|
946
|
+
with _span("llm.tool_args_unparsable",
|
|
947
|
+
tool=tc.function.name, raw=str(raw_args)[:200]):
|
|
948
|
+
pass
|
|
949
|
+
except Exception:
|
|
950
|
+
pass
|
|
951
|
+
calls.append({"id": tc.id, "name": tc.function.name, "args": args,
|
|
952
|
+
"args_parse_failed": parse_failed})
|
|
953
|
+
raw_tcs.append({"id": tc.id, "type": "function",
|
|
954
|
+
"function": {"name": tc.function.name,
|
|
955
|
+
"arguments": tc.function.arguments}})
|
|
956
|
+
assistant_msg = {"role": "assistant", "content": m.content or ""}
|
|
957
|
+
if raw_tcs:
|
|
958
|
+
assistant_msg["tool_calls"] = raw_tcs
|
|
959
|
+
return (m.content or ""), calls, assistant_msg
|
|
960
|
+
|
|
961
|
+
def _call_anthropic_tools(self, model, system, messages, tools, tool_choice, max_tokens):
|
|
962
|
+
"""anthropic 原生 tool_use:openai tools 格式 → anthropic 格式,调 messages.create,
|
|
963
|
+
解析 content block(tool_use 的 input 是原生结构化 dict,无需 JSON 解析)。
|
|
964
|
+
返回 (text, calls, assistant_msg)。
|
|
965
|
+
tool_choice=none → 映射 {type:none},但**仍传 tools**:anthropic 要求历史含 tool_use
|
|
966
|
+
block 时请求必须带 tools 定义,否则报错(收敛轮的历史里必有 tool_use)。"""
|
|
967
|
+
kwargs = {}
|
|
968
|
+
anthropic_tools = self._to_anthropic_tools(tools)
|
|
969
|
+
if anthropic_tools:
|
|
970
|
+
kwargs["tools"] = anthropic_tools
|
|
971
|
+
kwargs["tool_choice"] = self._to_anthropic_tool_choice(tool_choice)
|
|
972
|
+
resp = self._client.messages.create(
|
|
973
|
+
model=model, max_tokens=max_tokens, system=system,
|
|
974
|
+
messages=messages, **kwargs)
|
|
975
|
+
text_parts = []
|
|
976
|
+
calls = []
|
|
977
|
+
blocks = []
|
|
978
|
+
for b in (resp.content or []):
|
|
979
|
+
t = getattr(b, "type", "")
|
|
980
|
+
if t == "text":
|
|
981
|
+
txt = getattr(b, "text", "") or ""
|
|
982
|
+
text_parts.append(txt)
|
|
983
|
+
blocks.append({"type": "text", "text": txt})
|
|
984
|
+
elif t == "tool_use":
|
|
985
|
+
args = getattr(b, "input", None) or {}
|
|
986
|
+
calls.append({"id": getattr(b, "id", ""), "name": getattr(b, "name", ""),
|
|
987
|
+
"args": args, "args_parse_failed": False})
|
|
988
|
+
blocks.append({"type": "tool_use", "id": getattr(b, "id", ""),
|
|
989
|
+
"name": getattr(b, "name", ""), "input": args})
|
|
990
|
+
text = "".join(text_parts)
|
|
991
|
+
assistant_msg = {"role": "assistant", "content": blocks}
|
|
992
|
+
return text, calls, assistant_msg
|
|
993
|
+
|
|
994
|
+
def format_tool_result(self, tool_call: dict, result: str) -> dict:
|
|
995
|
+
"""把一次工具执行结果封装成 provider 原生消息,供追加进 messages 再喂回模型。
|
|
996
|
+
openai 家族:{role: tool, tool_call_id, content};
|
|
997
|
+
anthropic:{role: user, content:[{type: tool_result, tool_use_id, content}]}。"""
|
|
998
|
+
if self.provider == "anthropic":
|
|
999
|
+
return {"role": "user", "content": [
|
|
1000
|
+
{"type": "tool_result",
|
|
1001
|
+
"tool_use_id": tool_call.get("id", ""),
|
|
1002
|
+
"content": str(result)}]}
|
|
1003
|
+
return {"role": "tool", "tool_call_id": tool_call.get("id", ""),
|
|
1004
|
+
"content": str(result)}
|
|
1005
|
+
|
|
1006
|
+
def _stub(self, messages: list) -> str:
|
|
1007
|
+
"""无模型可用时的降级回复(never_configured 场景的合理兜底)。
|
|
1008
|
+
|
|
1009
|
+
诚实性契约(与 base.py §二「真相优先」对齐):
|
|
1010
|
+
· 措辞**不得**包含「已完成」「已处理」「已给出方案」等完成态动词
|
|
1011
|
+
—— 旧文案「已完成处理:检索相关记忆、分析要点…」把「什么都没做」
|
|
1012
|
+
包装成了「做完」,是假成功;
|
|
1013
|
+
· 必须显式告知未执行,并给出恢复动作;
|
|
1014
|
+
· 仍保留原 task_line 提取(贴合任务,便于 Harness 观察与用户识别)。
|
|
1015
|
+
注:故障降级(call_failed)路径**不再走本函数**,见 complete() 中 _mark_degraded 分支。
|
|
1016
|
+
"""
|
|
1017
|
+
user_text = str(messages[-1]["content"]) if messages else ""
|
|
1018
|
+
task_line = user_text
|
|
1019
|
+
for marker in ["任务:", "财务任务:", "行程任务:", "学习任务:",
|
|
1020
|
+
"请协助处理以下任务:", "请处理"]:
|
|
1021
|
+
if marker in user_text:
|
|
1022
|
+
task_line = user_text.split(marker, 1)[1]
|
|
1023
|
+
break
|
|
1024
|
+
task_line = task_line.strip().split("\n")[0][:80]
|
|
1025
|
+
return (f"【未执行】当前没有可用的模型服务,本次未进行推理,也没有执行任何工具。\n"
|
|
1026
|
+
f"待处理任务:「{task_line}」\n"
|
|
1027
|
+
f"恢复方式:配置任一模型 Key(见 .env.example)后重试。\n"
|
|
1028
|
+
f"(STUB · 此为降级占位,非真实结果)")
|
|
1029
|
+
|
|
1030
|
+
# ── 降级状态(A 类修复)────────────────────────────────────────────
|
|
1031
|
+
def _mark_degraded(self, reason: str) -> None:
|
|
1032
|
+
"""标记本次调用为「故障降级」(区别于 is_stub 的「从未配置」)。
|
|
1033
|
+
|
|
1034
|
+
上游读 is_degraded() / take_call_degraded() 决定:
|
|
1035
|
+
· 是否禁止把本次回复写入长期记忆(故障文本会污染召回);
|
|
1036
|
+
· 是否回传告警(便于前端/监控识别「这次不是真实结果」)。
|
|
1037
|
+
观测手段(tracing)包在 try 里 —— 可观测性是增强项,不能因它失败影响主流程。
|
|
1038
|
+
"""
|
|
1039
|
+
self.degraded = True
|
|
1040
|
+
self._call_degraded = True
|
|
1041
|
+
self.degraded_reason = ((self.degraded_reason + " | ")
|
|
1042
|
+
if self.degraded_reason else "") + str(reason)
|
|
1043
|
+
try:
|
|
1044
|
+
from .tracing import span as _span
|
|
1045
|
+
with _span("llm.degraded", provider=self.provider, reason=str(reason)[:200]):
|
|
1046
|
+
pass
|
|
1047
|
+
except Exception:
|
|
1048
|
+
pass
|
|
1049
|
+
|
|
1050
|
+
def is_degraded(self) -> bool:
|
|
1051
|
+
"""本次(或最近一次)调用是否因**故障**降级。区别于 is_stub(从未配置)。"""
|
|
1052
|
+
return bool(self.degraded)
|
|
1053
|
+
|
|
1054
|
+
def take_call_degraded(self) -> bool:
|
|
1055
|
+
"""取出并清零 per-call 降级标记(供单次请求生命周期内消费)。
|
|
1056
|
+
|
|
1057
|
+
per-call 而非全局:并发多请求时,只有**本次**真正故障的那个请求才该
|
|
1058
|
+
禁写记忆,不能因另一请求的故障误伤。
|
|
1059
|
+
"""
|
|
1060
|
+
v = self._call_degraded
|
|
1061
|
+
self._call_degraded = False
|
|
1062
|
+
return v
|
|
1063
|
+
|
|
1064
|
+
def switch(self, provider: str, model: str = None):
|
|
1065
|
+
"""运行时切换 provider(多模型路由的核心能力)。
|
|
1066
|
+
显式给 model → 锁定该模型(不分档,尊重用户选择);只切 provider(model 空)→ 清锁用分档。"""
|
|
1067
|
+
self.provider = provider
|
|
1068
|
+
self._locked_model = (model or "").strip() # 修复:显式模型必须生效(否则被 tiering 覆盖)
|
|
1069
|
+
self.model = self._locked_model or DEFAULT_MODELS.get(provider, self.model)
|
|
1070
|
+
self._init_client()
|
|
1071
|
+
return self.info()
|
|
1072
|
+
|
|
1073
|
+
def info(self) -> dict:
|
|
1074
|
+
return {
|
|
1075
|
+
"provider": self.provider,
|
|
1076
|
+
"model": self.model,
|
|
1077
|
+
"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
|
+
"degraded": self.degraded,
|
|
1084
|
+
"degraded_reason": self.degraded_reason[:200],
|
|
1085
|
+
"fallback_reason": self.fallback_reason,
|
|
1086
|
+
"client_ready": self._client is not None,
|
|
1087
|
+
"available_providers": self.available_providers(),
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
@staticmethod
|
|
1091
|
+
def available_providers() -> list:
|
|
1092
|
+
"""列出当前环境已配置 key 的 provider。"""
|
|
1093
|
+
out = []
|
|
1094
|
+
for p, env in PROVIDER_ENV.items():
|
|
1095
|
+
out.append({"provider": p, "configured": bool(os.environ.get(env)),
|
|
1096
|
+
"default_model": DEFAULT_MODELS.get(p, "")})
|
|
1097
|
+
return out
|