secagentx 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/__main__.py +3 -0
- backend/_assets/config.yaml +195 -0
- backend/_assets/frontend/dist/assets/AgentsView-DFqRplI8.css +1 -0
- backend/_assets/frontend/dist/assets/AgentsView-DJTpfQ87.js +1 -0
- backend/_assets/frontend/dist/assets/AttackChainView-BVfI-SC6.js +1 -0
- backend/_assets/frontend/dist/assets/ChatView-Bg4nIlQ0.css +1 -0
- backend/_assets/frontend/dist/assets/ChatView-CRndtmEC.js +38 -0
- backend/_assets/frontend/dist/assets/DashboardView-BXs4AWv8.js +1 -0
- backend/_assets/frontend/dist/assets/DashboardView-WnHWiqBW.css +1 -0
- backend/_assets/frontend/dist/assets/EventDetailView-DQk6Qp7-.js +1 -0
- backend/_assets/frontend/dist/assets/EventDetailView-Dj-a-zWr.css +1 -0
- backend/_assets/frontend/dist/assets/EventsView-8xh8uKO0.js +1 -0
- backend/_assets/frontend/dist/assets/EventsView-BHfFn66K.css +1 -0
- backend/_assets/frontend/dist/assets/KnowledgeView-C2N3v9bF.css +1 -0
- backend/_assets/frontend/dist/assets/KnowledgeView-D54ODoC9.js +11 -0
- backend/_assets/frontend/dist/assets/LoginView-CjvFmo7C.js +1 -0
- backend/_assets/frontend/dist/assets/LoginView-D14FzcsP.css +1 -0
- backend/_assets/frontend/dist/assets/SettingsView-JOJuoABi.js +1 -0
- backend/_assets/frontend/dist/assets/UsersView-DhMXZhir.js +1 -0
- backend/_assets/frontend/dist/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
- backend/_assets/frontend/dist/assets/index-D9Su3fYY.css +1 -0
- backend/_assets/frontend/dist/assets/index-DBEjbyJR.js +4 -0
- backend/_assets/frontend/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
- backend/_assets/frontend/dist/assets/vendor-charts-Dh1nChzO.js +39 -0
- backend/_assets/frontend/dist/assets/vendor-icons-BTNgi7vU.js +1 -0
- backend/_assets/frontend/dist/assets/vendor-ui-Cdd65KTN.js +7367 -0
- backend/_assets/frontend/dist/assets/vendor-vue-CXKcZAmz.js +1 -0
- backend/_assets/frontend/dist/index.html +51 -0
- backend/_assets/frontend/index.html +46 -0
- backend/_assets/frontend/package-lock.json +2983 -0
- backend/_assets/frontend/package.json +37 -0
- backend/_assets/frontend/src/App.vue +231 -0
- backend/_assets/frontend/src/main.js +12 -0
- backend/_assets/frontend/src/router/index.js +117 -0
- backend/_assets/frontend/src/stores/chat.js +671 -0
- backend/_assets/frontend/src/styles/tokens.css +282 -0
- backend/_assets/frontend/src/utils/http.js +191 -0
- backend/_assets/frontend/src/views/AgentsView.vue +227 -0
- backend/_assets/frontend/src/views/AttackChainView.vue +210 -0
- backend/_assets/frontend/src/views/ChatView.vue +1107 -0
- backend/_assets/frontend/src/views/DashboardView.vue +514 -0
- backend/_assets/frontend/src/views/EventDetailView.vue +387 -0
- backend/_assets/frontend/src/views/EventsView.vue +253 -0
- backend/_assets/frontend/src/views/KnowledgeView.vue +883 -0
- backend/_assets/frontend/src/views/LoginView.vue +314 -0
- backend/_assets/frontend/src/views/SettingsView.vue +226 -0
- backend/_assets/frontend/src/views/UsersView.vue +235 -0
- backend/_assets/frontend/vite.config.js +55 -0
- backend/_assets/knowledge_data/compliance/regulations.json +383 -0
- backend/_assets/knowledge_data/cve/vulnerabilities.json +70720 -0
- backend/_assets/knowledge_data/mitre_attack/techniques.json +14097 -0
- backend/_assets/knowledge_data/remediation/remediation.json +574 -0
- backend/_assets/knowledge_data/threat_intel/actors.json +1602 -0
- backend/_assets/knowledge_data/threat_intel/malware.json +971 -0
- backend/agents/__init__.py +1 -0
- backend/agents/alert_filter/__init__.py +1 -0
- backend/agents/alert_filter/alert_filter_agent.py +88 -0
- backend/agents/analyst.py +331 -0
- backend/agents/base.py +553 -0
- backend/agents/classifier_agent.py +134 -0
- backend/agents/cot_engine.py +578 -0
- backend/agents/intel.py +46 -0
- backend/agents/knowledge/__init__.py +2 -0
- backend/agents/knowledge/agentic_rag.py +928 -0
- backend/agents/knowledge/knowledge_agent.py +47 -0
- backend/agents/registry.py +148 -0
- backend/agents/responder.py +51 -0
- backend/agents/summary_agent.py +219 -0
- backend/auto_ingestor.py +948 -0
- backend/auto_patrol.py +452 -0
- backend/cli_app.py +523 -0
- backend/config/__init__.py +20 -0
- backend/config/provider_profiles.py +416 -0
- backend/config/runtime_settings.py +84 -0
- backend/decision_fusion/__init__.py +32 -0
- backend/decision_fusion/base.py +127 -0
- backend/decision_fusion/dempster_shafer.py +419 -0
- backend/decision_fusion/weighted_average.py +141 -0
- backend/email_ingestor.py +588 -0
- backend/errors.py +243 -0
- backend/escalation.py +453 -0
- backend/evaluation/__init__.py +7 -0
- backend/evaluation/evaluator.py +197 -0
- backend/evaluation/metrics.py +210 -0
- backend/federation/__init__.py +22 -0
- backend/federation/core.py +743 -0
- backend/interface/__init__.py +1 -0
- backend/interface/api_server.py +1830 -0
- backend/interface/cli.py +671 -0
- backend/knowledge/__init__.py +11 -0
- backend/knowledge/compliance.py +101 -0
- backend/knowledge/cve_db.py +100 -0
- backend/knowledge/mitre_attack.py +457 -0
- backend/knowledge/remediation.py +84 -0
- backend/knowledge/threat_intel_kb.py +246 -0
- backend/llm/__init__.py +1 -0
- backend/llm/anthropic_compatible.py +191 -0
- backend/llm/base.py +81 -0
- backend/llm/deepseek.py +221 -0
- backend/llm/fallback.py +268 -0
- backend/llm/mock.py +204 -0
- backend/llm/openai_compatible.py +51 -0
- backend/llm/provider.py +217 -0
- backend/llm/qwen.py +218 -0
- backend/main.py +421 -0
- backend/memory/__init__.py +1 -0
- backend/memory/episodic_memory.py +132 -0
- backend/memory/manager.py +204 -0
- backend/memory/semantic_memory.py +156 -0
- backend/memory/session_memory.py +102 -0
- backend/ml_model/__init__.py +23 -0
- backend/ml_model/pipeline.py +191 -0
- backend/ml_model/trainer.py +627 -0
- backend/models/__init__.py +1 -0
- backend/models/event.py +62 -0
- backend/models/message.py +58 -0
- backend/models/output.py +1216 -0
- backend/monitoring/metrics.py +184 -0
- backend/orchestrator/__init__.py +17 -0
- backend/orchestrator/agent_router.py +145 -0
- backend/orchestrator/core.py +159 -0
- backend/orchestrator/dynamic_weights.py +287 -0
- backend/orchestrator/react_loop.py +1895 -0
- backend/orchestrator/risk_model.py +494 -0
- backend/orchestrator/risk_scorer.py +356 -0
- backend/reasoner/__init__.py +19 -0
- backend/reasoner/reasoner.py +316 -0
- backend/runtime_assets.py +45 -0
- backend/security/__init__.py +24 -0
- backend/security/adversarial/__init__.py +13 -0
- backend/security/adversarial/guard.py +121 -0
- backend/security/adversarial/redteam.py +121 -0
- backend/security/adversarial/scanner.py +208 -0
- backend/security/audit.py +118 -0
- backend/security/auth.py +283 -0
- backend/security/circuit_breaker.py +212 -0
- backend/security/dispatch.py +18 -0
- backend/security/ratelimit.py +237 -0
- backend/security/rbac.py +431 -0
- backend/security/refresh_sessions.py +193 -0
- backend/security/sanitizer.py +32 -0
- backend/storage/__init__.py +1 -0
- backend/storage/chroma_store.py +79 -0
- backend/storage/database.py +296 -0
- backend/storage/migrations/env.py +65 -0
- backend/storage/migrations/versions/0001_initial_schema.py +101 -0
- backend/storage/migrations/versions/0002_add_agent_logs_experiences.py +53 -0
- backend/storage/migrations/versions/0003_add_conversation_owner.py +30 -0
- backend/storage/models.py +194 -0
- backend/storage/repositories/__init__.py +1 -0
- backend/storage/repositories/asset_repo.py +86 -0
- backend/storage/repositories/conversation_repo.py +216 -0
- backend/storage/repositories/event_repo.py +76 -0
- backend/storage/repositories/trajectory_repo.py +324 -0
- backend/storage/retention.py +117 -0
- backend/tools/__init__.py +16 -0
- backend/tools/alert_filter.py +208 -0
- backend/tools/aliyun_firewall.py +237 -0
- backend/tools/aws_firewall.py +369 -0
- backend/tools/base.py +445 -0
- backend/tools/calling.py +207 -0
- backend/tools/cve_search.py +77 -0
- backend/tools/execution_engine.py +202 -0
- backend/tools/firewall.py +387 -0
- backend/tools/geoip.py +168 -0
- backend/tools/log_analyzer.py +185 -0
- backend/tools/ml_detector.py +226 -0
- backend/tools/registry.py +38 -0
- backend/tools/tencent_firewall.py +300 -0
- backend/tools/threat_intel.py +732 -0
- backend/tools/trust_checker.py +76 -0
- backend/utils/__init__.py +5 -0
- backend/utils/config_loader.py +164 -0
- backend/utils/env.py +75 -0
- backend/utils/logger.py +150 -0
- backend/utils/text.py +32 -0
- secagentx-3.0.0.dist-info/METADATA +325 -0
- secagentx-3.0.0.dist-info/RECORD +182 -0
- secagentx-3.0.0.dist-info/WHEEL +5 -0
- secagentx-3.0.0.dist-info/entry_points.txt +2 -0
- secagentx-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""通用 OpenAI Chat Completions 兼容 Provider。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .base import LLMConfig
|
|
10
|
+
from .deepseek import DeepSeekProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAICompatibleProvider(DeepSeekProvider):
|
|
14
|
+
"""支持任意实现 OpenAI `/chat/completions` 协议的云端或本地服务。"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: Optional[dict] = None):
|
|
17
|
+
cfg = config or {}
|
|
18
|
+
self.config = LLMConfig(
|
|
19
|
+
api_base=str(cfg.get("api_base", "")).rstrip("/"),
|
|
20
|
+
api_key=cfg.get("api_key", ""),
|
|
21
|
+
model=cfg.get("model", ""),
|
|
22
|
+
temperature=float(cfg.get("temperature", 0.1)),
|
|
23
|
+
max_tokens=int(cfg.get("max_tokens", 4096)),
|
|
24
|
+
timeout_seconds=float(cfg.get("timeout_seconds", 60)),
|
|
25
|
+
)
|
|
26
|
+
if not self.config.api_base:
|
|
27
|
+
raise ValueError("OpenAI 兼容 Provider 缺少 api_base")
|
|
28
|
+
if not self.config.model:
|
|
29
|
+
raise ValueError("OpenAI 兼容 Provider 缺少 model")
|
|
30
|
+
self.auth_style = cfg.get("auth_style", "bearer")
|
|
31
|
+
self.api_version = cfg.get("api_version", "")
|
|
32
|
+
self.allow_no_key = bool(cfg.get("allow_no_key", False))
|
|
33
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def client(self) -> httpx.AsyncClient:
|
|
37
|
+
if self._http_client is None:
|
|
38
|
+
headers = {"Content-Type": "application/json"}
|
|
39
|
+
if self.config.api_key and not self.allow_no_key:
|
|
40
|
+
if self.auth_style == "api-key":
|
|
41
|
+
headers["api-key"] = self.config.api_key
|
|
42
|
+
else:
|
|
43
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
44
|
+
params = {"api-version": self.api_version} if self.api_version else None
|
|
45
|
+
self._http_client = httpx.AsyncClient(
|
|
46
|
+
base_url=self.config.api_base,
|
|
47
|
+
timeout=self.config.timeout_seconds,
|
|
48
|
+
headers=headers,
|
|
49
|
+
params=params,
|
|
50
|
+
)
|
|
51
|
+
return self._http_client
|
backend/llm/provider.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from .base import LLMInterface
|
|
7
|
+
from .deepseek import DeepSeekProvider
|
|
8
|
+
from .qwen import QwenProvider
|
|
9
|
+
from .openai_compatible import OpenAICompatibleProvider
|
|
10
|
+
from .anthropic_compatible import AnthropicCompatibleProvider
|
|
11
|
+
from .mock import MockLLMProvider, is_mock_key
|
|
12
|
+
from .fallback import FallbackLLMProvider
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("secagentx.llm")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cache_fingerprint(*values) -> str:
|
|
18
|
+
"""生成稳定且不在缓存键中保留明文 API Key 的配置指纹。"""
|
|
19
|
+
payload = json.dumps(values, sort_keys=True, ensure_ascii=False, default=repr)
|
|
20
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_runtime_profile(provider: str, config: dict) -> tuple[str, dict, bool]:
|
|
24
|
+
"""用 onboarding 选定的活动档案覆盖硬编码 Agent Provider。"""
|
|
25
|
+
active = os.getenv("SECAGENTX_ACTIVE_PROVIDER", "").strip().lower()
|
|
26
|
+
if not active:
|
|
27
|
+
return provider, dict(config), False
|
|
28
|
+
runtime = dict(config)
|
|
29
|
+
env_map = {
|
|
30
|
+
"api_base": "SECAGENTX_LLM_API_BASE",
|
|
31
|
+
"api_key": "SECAGENTX_LLM_API_KEY",
|
|
32
|
+
"model": "SECAGENTX_LLM_MODEL",
|
|
33
|
+
"auth_style": "SECAGENTX_LLM_AUTH_STYLE",
|
|
34
|
+
"api_version": "SECAGENTX_LLM_API_VERSION",
|
|
35
|
+
}
|
|
36
|
+
for field, env_name in env_map.items():
|
|
37
|
+
value = os.getenv(env_name, "")
|
|
38
|
+
if value:
|
|
39
|
+
runtime[field] = value
|
|
40
|
+
runtime["allow_no_key"] = os.getenv("SECAGENTX_LLM_ALLOW_NO_KEY", "").lower() in (
|
|
41
|
+
"1", "true", "yes",
|
|
42
|
+
)
|
|
43
|
+
return active, runtime, True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_provider(provider: str, config: dict, *, use_runtime_profile: bool = True) -> LLMInterface:
|
|
47
|
+
"""构建单个 LLM 提供者(不含 fallback 包装)。"""
|
|
48
|
+
if use_runtime_profile:
|
|
49
|
+
provider, config, _ = _resolve_runtime_profile(provider, config)
|
|
50
|
+
else:
|
|
51
|
+
config = dict(config)
|
|
52
|
+
if provider == "mock":
|
|
53
|
+
return MockLLMProvider(config)
|
|
54
|
+
api_key = config.get("api_key") or os.getenv(f"{provider.upper()}_API_KEY", "")
|
|
55
|
+
config["api_key"] = api_key
|
|
56
|
+
|
|
57
|
+
# 企业运行时禁止把缺失凭据静默伪装成真实模型响应。
|
|
58
|
+
# Mock 只能通过 provider=mock 显式选择;测试代码可继续直接使用 MockLLMProvider。
|
|
59
|
+
if is_mock_key(api_key) and not config.get("allow_no_key", False):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"{provider} 未配置有效 API Key;请运行 `secagentx onboard`,"
|
|
62
|
+
"或显式选择 provider=mock(仅限测试/演示)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if provider == "deepseek":
|
|
66
|
+
return DeepSeekProvider(config)
|
|
67
|
+
elif provider == "qwen":
|
|
68
|
+
return QwenProvider(config)
|
|
69
|
+
elif provider == "openai_compatible":
|
|
70
|
+
return OpenAICompatibleProvider(config)
|
|
71
|
+
elif provider == "anthropic_compatible":
|
|
72
|
+
return AnthropicCompatibleProvider(config)
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(f"Unknown LLM provider: {provider}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_single_provider(provider: str, config: dict) -> LLMInterface:
|
|
78
|
+
"""向后兼容的内部别名。"""
|
|
79
|
+
return build_provider(provider, config)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LLMFactory:
|
|
83
|
+
_instances: dict[str, LLMInterface] = {}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def get_provider(cls, provider: str = "deepseek",
|
|
87
|
+
config: Optional[dict] = None,
|
|
88
|
+
fallback_config: Optional[dict] = None) -> LLMInterface:
|
|
89
|
+
"""
|
|
90
|
+
获取 LLM 提供者(自动包装 FallbackLLMProvider)。
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
provider: 主 LLM 名称或兼容协议名称
|
|
94
|
+
config: 主 LLM 配置字典
|
|
95
|
+
fallback_config: fallback 配置,结构为:
|
|
96
|
+
{
|
|
97
|
+
"enabled": True,
|
|
98
|
+
"provider": "qwen", # 备用 LLM 名称
|
|
99
|
+
"config": {...}, # 备用 LLM 配置
|
|
100
|
+
"timeout_seconds": 25, # 超时阈值
|
|
101
|
+
"fallback_on_error": True,
|
|
102
|
+
}
|
|
103
|
+
"""
|
|
104
|
+
if config is None:
|
|
105
|
+
config = {}
|
|
106
|
+
|
|
107
|
+
provider, config, has_runtime_profile = _resolve_runtime_profile(provider, config)
|
|
108
|
+
if has_runtime_profile:
|
|
109
|
+
# onboarding 档案是全局统一路由;避免旧 qwen fallback 再次调用同一接口。
|
|
110
|
+
fallback_config = None
|
|
111
|
+
|
|
112
|
+
key = f"{provider}:{_cache_fingerprint(config, fallback_config)}"
|
|
113
|
+
if key in cls._instances:
|
|
114
|
+
return cls._instances[key]
|
|
115
|
+
|
|
116
|
+
# 构建主 LLM
|
|
117
|
+
primary = build_provider(provider, config, use_runtime_profile=False)
|
|
118
|
+
|
|
119
|
+
# 检查是否需要包装 FallbackLLMProvider
|
|
120
|
+
if fallback_config and fallback_config.get("enabled", False):
|
|
121
|
+
fb_provider_name = fallback_config.get("provider", "")
|
|
122
|
+
fb_cfg = dict(fallback_config.get("config", {}))
|
|
123
|
+
if fb_provider_name:
|
|
124
|
+
# ═══ 修复:fallback 未显式配置时,从主 provider 继承关键字段 ═══
|
|
125
|
+
# 问题:config.yaml 的 fallback 段常缺少嵌套 config,
|
|
126
|
+
# 导致 QwenProvider 用默认模型 qwen2.5-72b-instruct(该模型对许多账号 403 access_denied)
|
|
127
|
+
# 方案:模型只从【同 provider】继承(跨 provider 继承会拿到错误模型名);
|
|
128
|
+
# api_key/api_base 允许跨 provider 继承(环境变量兜底)。
|
|
129
|
+
if not fb_cfg:
|
|
130
|
+
if fb_provider_name == provider:
|
|
131
|
+
# 同 provider 兜底:完整继承主配置(含 model)
|
|
132
|
+
fb_cfg = dict(config)
|
|
133
|
+
else:
|
|
134
|
+
# 跨 provider:只继承 api_base / api_key,model 用各 provider 显式配置或默认
|
|
135
|
+
fb_cfg = {
|
|
136
|
+
k: config.get(k) for k in ("api_base", "api_key")
|
|
137
|
+
if config.get(k)
|
|
138
|
+
}
|
|
139
|
+
logger.warning(
|
|
140
|
+
"fallback(%s) 未提供嵌套 config,已从主 provider 继承字段: %s%s",
|
|
141
|
+
fb_provider_name, sorted(fb_cfg.keys()),
|
|
142
|
+
"(含 model,同 provider)" if fb_provider_name == provider else
|
|
143
|
+
"(model 用 fallback provider 默认,建议在 config 显式指定)",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
# 部分提供 → 逐个字段继承缺失项(model 仅同 provider 继承)
|
|
147
|
+
for k in ("api_base", "api_key"):
|
|
148
|
+
if not fb_cfg.get(k) and config.get(k):
|
|
149
|
+
fb_cfg[k] = config[k]
|
|
150
|
+
if fb_provider_name == provider and not fb_cfg.get("model") and config.get("model"):
|
|
151
|
+
fb_cfg["model"] = config["model"]
|
|
152
|
+
try:
|
|
153
|
+
fallback = build_provider(fb_provider_name, fb_cfg, use_runtime_profile=False)
|
|
154
|
+
# ═══ 修复:模型名黑名单校验(防止 403 access_denied 静默失败) ═══
|
|
155
|
+
# 必须基于【实际构建出的 fallback 实例】校验 —— 因为 QwenProvider 构造时
|
|
156
|
+
# 会用默认 qwen2.5-72b-instruct 填充 fb_cfg 里缺失的 model。
|
|
157
|
+
_BLOCKED_MODELS = (
|
|
158
|
+
"qwen2.5-72b-instruct", "qwen2.5-7b-instruct",
|
|
159
|
+
)
|
|
160
|
+
fb_actual_model = getattr(fallback, "config", None)
|
|
161
|
+
fb_actual_model = fb_actual_model.model if hasattr(fb_actual_model, "model") else ""
|
|
162
|
+
if (fb_actual_model or "").lower() in _BLOCKED_MODELS:
|
|
163
|
+
# 同 provider 优先回退主模型;跨 provider 用该 provider 的常用模型
|
|
164
|
+
corrected = (
|
|
165
|
+
config.get("model") if fb_provider_name == provider
|
|
166
|
+
else {"qwen": "qwen-max", "deepseek": "deepseek-chat"}.get(
|
|
167
|
+
fb_provider_name, fb_actual_model)
|
|
168
|
+
)
|
|
169
|
+
logger.warning(
|
|
170
|
+
"fallback(%s) 模型 %s 不可用(常见 403 access_denied),"
|
|
171
|
+
"已纠正为 %s",
|
|
172
|
+
fb_provider_name, fb_actual_model, corrected,
|
|
173
|
+
)
|
|
174
|
+
fb_cfg["model"] = corrected
|
|
175
|
+
fallback = build_provider(fb_provider_name, fb_cfg, use_runtime_profile=False)
|
|
176
|
+
wrapper = FallbackLLMProvider(
|
|
177
|
+
primary,
|
|
178
|
+
fallback,
|
|
179
|
+
fallback_on_timeout=fallback_config.get("fallback_on_timeout", True),
|
|
180
|
+
fallback_on_error=fallback_config.get("fallback_on_error", True),
|
|
181
|
+
timeout_seconds=float(fallback_config.get("timeout_seconds", 30)),
|
|
182
|
+
)
|
|
183
|
+
logger.info(
|
|
184
|
+
"FallbackLLM: primary=%s, fallback=%s, timeout=%.1fs",
|
|
185
|
+
provider, fb_provider_name,
|
|
186
|
+
float(fallback_config.get("timeout_seconds", 30)),
|
|
187
|
+
)
|
|
188
|
+
cls._instances[key] = wrapper
|
|
189
|
+
return wrapper
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning("FallbackLLM 构建失败(仅使用主 LLM): %s", e)
|
|
192
|
+
|
|
193
|
+
# 无 fallback 配置或构建失败 → 直接返回主 LLM
|
|
194
|
+
cls._instances[key] = primary
|
|
195
|
+
return primary
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def get_deepseek(cls, config: Optional[dict] = None,
|
|
199
|
+
fallback_config: Optional[dict] = None) -> LLMInterface:
|
|
200
|
+
return cls.get_provider("deepseek", config, fallback_config)
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def get_qwen(cls, config: Optional[dict] = None,
|
|
204
|
+
fallback_config: Optional[dict] = None) -> LLMInterface:
|
|
205
|
+
return cls.get_provider("qwen", config, fallback_config)
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def clear(cls):
|
|
209
|
+
for inst in cls._instances.values():
|
|
210
|
+
try:
|
|
211
|
+
import asyncio
|
|
212
|
+
loop = asyncio.get_event_loop()
|
|
213
|
+
if loop.is_running():
|
|
214
|
+
loop.create_task(inst.close())
|
|
215
|
+
except RuntimeError:
|
|
216
|
+
pass
|
|
217
|
+
cls._instances.clear()
|
backend/llm/qwen.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import AsyncGenerator, Optional
|
|
4
|
+
from .base import LLMInterface, LLMConfig, LLMResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QwenProvider(LLMInterface):
|
|
8
|
+
def __init__(self, config: Optional[dict] = None):
|
|
9
|
+
cfg = config or {}
|
|
10
|
+
self.config = LLMConfig(
|
|
11
|
+
api_base=cfg.get("api_base", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
|
12
|
+
api_key=cfg.get("api_key", ""),
|
|
13
|
+
model=cfg.get("model", "qwen2.5-72b-instruct"),
|
|
14
|
+
temperature=cfg.get("temperature", 0.3),
|
|
15
|
+
max_tokens=cfg.get("max_tokens", 4096),
|
|
16
|
+
)
|
|
17
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def client(self) -> httpx.AsyncClient:
|
|
21
|
+
if self._http_client is None:
|
|
22
|
+
self._http_client = httpx.AsyncClient(
|
|
23
|
+
base_url=self.config.api_base,
|
|
24
|
+
timeout=self.config.timeout_seconds,
|
|
25
|
+
headers={
|
|
26
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
return self._http_client
|
|
31
|
+
|
|
32
|
+
async def chat(self, messages: list[dict], stream: bool = False) -> LLMResponse:
|
|
33
|
+
payload = {
|
|
34
|
+
"model": self.config.model,
|
|
35
|
+
"messages": messages,
|
|
36
|
+
"temperature": self.config.temperature,
|
|
37
|
+
"max_tokens": self.config.max_tokens,
|
|
38
|
+
"stream": stream,
|
|
39
|
+
}
|
|
40
|
+
resp = await self.client.post("/chat/completions", json=payload)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
data = resp.json()
|
|
43
|
+
choice = data["choices"][0]
|
|
44
|
+
return LLMResponse(
|
|
45
|
+
content=choice["message"]["content"],
|
|
46
|
+
usage=data.get("usage", {}),
|
|
47
|
+
model=data.get("model", self.config.model),
|
|
48
|
+
finish_reason=choice.get("finish_reason", ""),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def chat_stream(self, messages: list[dict]) -> AsyncGenerator[str, None]:
|
|
52
|
+
payload = {
|
|
53
|
+
"model": self.config.model,
|
|
54
|
+
"messages": messages,
|
|
55
|
+
"temperature": self.config.temperature,
|
|
56
|
+
"max_tokens": self.config.max_tokens,
|
|
57
|
+
"stream": True,
|
|
58
|
+
}
|
|
59
|
+
self._last_usage = {}
|
|
60
|
+
async with self.client.stream("POST", "/chat/completions", json=payload) as resp:
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
async for line in resp.aiter_lines():
|
|
63
|
+
if not line.startswith("data: "):
|
|
64
|
+
continue
|
|
65
|
+
chunk = line[6:]
|
|
66
|
+
if chunk == "[DONE]":
|
|
67
|
+
break
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(chunk)
|
|
70
|
+
if "usage" in data and data["usage"]:
|
|
71
|
+
self._last_usage = data["usage"]
|
|
72
|
+
delta = data["choices"][0].get("delta", {})
|
|
73
|
+
content = delta.get("content", "")
|
|
74
|
+
if content:
|
|
75
|
+
yield content
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
async def structured_output(self, messages: list[dict], response_model: type = None) -> dict:
|
|
80
|
+
payload = {
|
|
81
|
+
"model": self.config.model,
|
|
82
|
+
"messages": messages,
|
|
83
|
+
"temperature": 0.1,
|
|
84
|
+
"max_tokens": self.config.max_tokens,
|
|
85
|
+
"response_format": {"type": "json_object"},
|
|
86
|
+
}
|
|
87
|
+
resp = await self.client.post("/chat/completions", json=payload)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
data = resp.json()
|
|
90
|
+
content = data["choices"][0]["message"]["content"]
|
|
91
|
+
|
|
92
|
+
json_start = content.find("{")
|
|
93
|
+
json_end = content.rfind("}")
|
|
94
|
+
if json_start != -1 and json_end != -1:
|
|
95
|
+
try:
|
|
96
|
+
return json.loads(content[json_start : json_end + 1])
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(content)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
# 容错修复:剥围栏/去尾逗号等,仍失败才返回失败标记
|
|
104
|
+
from ..models.output import repair_json_object
|
|
105
|
+
repaired = repair_json_object(content)
|
|
106
|
+
if repaired:
|
|
107
|
+
return repaired
|
|
108
|
+
return {"raw": content, "parse_error": True}
|
|
109
|
+
|
|
110
|
+
# ═══════════════════════ Function Calling ═══════════════════════
|
|
111
|
+
|
|
112
|
+
async def chat_with_tools(
|
|
113
|
+
self,
|
|
114
|
+
messages: list[dict],
|
|
115
|
+
tools: list[dict],
|
|
116
|
+
tool_choice: str = "auto",
|
|
117
|
+
) -> tuple[str, list[dict]]:
|
|
118
|
+
"""
|
|
119
|
+
Qwen Function Calling
|
|
120
|
+
|
|
121
|
+
通义千问 DashScope 兼容模式支持 OpenAI 格式的 tools 参数。
|
|
122
|
+
"""
|
|
123
|
+
payload = {
|
|
124
|
+
"model": self.config.model,
|
|
125
|
+
"messages": messages,
|
|
126
|
+
"temperature": self.config.temperature,
|
|
127
|
+
"max_tokens": self.config.max_tokens,
|
|
128
|
+
"tools": tools,
|
|
129
|
+
"tool_choice": tool_choice,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
resp = await self.client.post("/chat/completions", json=payload)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
data = resp.json()
|
|
135
|
+
|
|
136
|
+
message = data["choices"][0].get("message", {})
|
|
137
|
+
content = message.get("content", "") or ""
|
|
138
|
+
tool_calls = message.get("tool_calls", [])
|
|
139
|
+
|
|
140
|
+
if "usage" in data:
|
|
141
|
+
self._last_usage = data["usage"]
|
|
142
|
+
|
|
143
|
+
return content, tool_calls
|
|
144
|
+
|
|
145
|
+
async def chat_with_tools_stream(
|
|
146
|
+
self,
|
|
147
|
+
messages: list[dict],
|
|
148
|
+
tools: list[dict],
|
|
149
|
+
tool_choice: str = "auto",
|
|
150
|
+
) -> AsyncGenerator[dict, None]:
|
|
151
|
+
"""
|
|
152
|
+
Qwen 流式 Function Calling
|
|
153
|
+
"""
|
|
154
|
+
payload = {
|
|
155
|
+
"model": self.config.model,
|
|
156
|
+
"messages": messages,
|
|
157
|
+
"temperature": self.config.temperature,
|
|
158
|
+
"max_tokens": self.config.max_tokens,
|
|
159
|
+
"tools": tools,
|
|
160
|
+
"tool_choice": tool_choice,
|
|
161
|
+
"stream": True,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
self._last_usage = {}
|
|
165
|
+
tool_calls_acc: dict[int, dict] = {}
|
|
166
|
+
|
|
167
|
+
async with self.client.stream(
|
|
168
|
+
"POST", "/chat/completions", json=payload
|
|
169
|
+
) as resp:
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
async for line in resp.aiter_lines():
|
|
172
|
+
if not line.startswith("data: "):
|
|
173
|
+
continue
|
|
174
|
+
chunk = line[6:]
|
|
175
|
+
if chunk == "[DONE]":
|
|
176
|
+
break
|
|
177
|
+
try:
|
|
178
|
+
data = json.loads(chunk)
|
|
179
|
+
if "usage" in data and data["usage"]:
|
|
180
|
+
self._last_usage = data["usage"]
|
|
181
|
+
|
|
182
|
+
delta = data["choices"][0].get("delta", {})
|
|
183
|
+
|
|
184
|
+
content = delta.get("content", "")
|
|
185
|
+
if content:
|
|
186
|
+
yield {"type": "text", "content": content}
|
|
187
|
+
|
|
188
|
+
for tc in delta.get("tool_calls", []):
|
|
189
|
+
idx = tc.get("index", 0)
|
|
190
|
+
if idx not in tool_calls_acc:
|
|
191
|
+
tool_calls_acc[idx] = {
|
|
192
|
+
"id": tc.get("id", ""),
|
|
193
|
+
"type": "function",
|
|
194
|
+
"function": {"name": "", "arguments": ""},
|
|
195
|
+
}
|
|
196
|
+
if tc.get("id"):
|
|
197
|
+
tool_calls_acc[idx]["id"] = tc["id"]
|
|
198
|
+
if "function" in tc:
|
|
199
|
+
fn = tc["function"]
|
|
200
|
+
if fn.get("name"):
|
|
201
|
+
tool_calls_acc[idx]["function"]["name"] += fn["name"]
|
|
202
|
+
if fn.get("arguments"):
|
|
203
|
+
tool_calls_acc[idx]["function"]["arguments"] += fn["arguments"]
|
|
204
|
+
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if tool_calls_acc:
|
|
209
|
+
sorted_calls = [
|
|
210
|
+
tool_calls_acc[i] for i in sorted(tool_calls_acc.keys())
|
|
211
|
+
]
|
|
212
|
+
yield {"type": "tool_calls", "tool_calls": sorted_calls}
|
|
213
|
+
|
|
214
|
+
async def close(self):
|
|
215
|
+
if self._http_client:
|
|
216
|
+
await self._http_client.aclose()
|
|
217
|
+
self._http_client = None
|
|
218
|
+
|