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