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.
Files changed (182) hide show
  1. backend/__init__.py +1 -0
  2. backend/__main__.py +3 -0
  3. backend/_assets/config.yaml +195 -0
  4. backend/_assets/frontend/dist/assets/AgentsView-DFqRplI8.css +1 -0
  5. backend/_assets/frontend/dist/assets/AgentsView-DJTpfQ87.js +1 -0
  6. backend/_assets/frontend/dist/assets/AttackChainView-BVfI-SC6.js +1 -0
  7. backend/_assets/frontend/dist/assets/ChatView-Bg4nIlQ0.css +1 -0
  8. backend/_assets/frontend/dist/assets/ChatView-CRndtmEC.js +38 -0
  9. backend/_assets/frontend/dist/assets/DashboardView-BXs4AWv8.js +1 -0
  10. backend/_assets/frontend/dist/assets/DashboardView-WnHWiqBW.css +1 -0
  11. backend/_assets/frontend/dist/assets/EventDetailView-DQk6Qp7-.js +1 -0
  12. backend/_assets/frontend/dist/assets/EventDetailView-Dj-a-zWr.css +1 -0
  13. backend/_assets/frontend/dist/assets/EventsView-8xh8uKO0.js +1 -0
  14. backend/_assets/frontend/dist/assets/EventsView-BHfFn66K.css +1 -0
  15. backend/_assets/frontend/dist/assets/KnowledgeView-C2N3v9bF.css +1 -0
  16. backend/_assets/frontend/dist/assets/KnowledgeView-D54ODoC9.js +11 -0
  17. backend/_assets/frontend/dist/assets/LoginView-CjvFmo7C.js +1 -0
  18. backend/_assets/frontend/dist/assets/LoginView-D14FzcsP.css +1 -0
  19. backend/_assets/frontend/dist/assets/SettingsView-JOJuoABi.js +1 -0
  20. backend/_assets/frontend/dist/assets/UsersView-DhMXZhir.js +1 -0
  21. backend/_assets/frontend/dist/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  22. backend/_assets/frontend/dist/assets/index-D9Su3fYY.css +1 -0
  23. backend/_assets/frontend/dist/assets/index-DBEjbyJR.js +4 -0
  24. backend/_assets/frontend/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
  25. backend/_assets/frontend/dist/assets/vendor-charts-Dh1nChzO.js +39 -0
  26. backend/_assets/frontend/dist/assets/vendor-icons-BTNgi7vU.js +1 -0
  27. backend/_assets/frontend/dist/assets/vendor-ui-Cdd65KTN.js +7367 -0
  28. backend/_assets/frontend/dist/assets/vendor-vue-CXKcZAmz.js +1 -0
  29. backend/_assets/frontend/dist/index.html +51 -0
  30. backend/_assets/frontend/index.html +46 -0
  31. backend/_assets/frontend/package-lock.json +2983 -0
  32. backend/_assets/frontend/package.json +37 -0
  33. backend/_assets/frontend/src/App.vue +231 -0
  34. backend/_assets/frontend/src/main.js +12 -0
  35. backend/_assets/frontend/src/router/index.js +117 -0
  36. backend/_assets/frontend/src/stores/chat.js +671 -0
  37. backend/_assets/frontend/src/styles/tokens.css +282 -0
  38. backend/_assets/frontend/src/utils/http.js +191 -0
  39. backend/_assets/frontend/src/views/AgentsView.vue +227 -0
  40. backend/_assets/frontend/src/views/AttackChainView.vue +210 -0
  41. backend/_assets/frontend/src/views/ChatView.vue +1107 -0
  42. backend/_assets/frontend/src/views/DashboardView.vue +514 -0
  43. backend/_assets/frontend/src/views/EventDetailView.vue +387 -0
  44. backend/_assets/frontend/src/views/EventsView.vue +253 -0
  45. backend/_assets/frontend/src/views/KnowledgeView.vue +883 -0
  46. backend/_assets/frontend/src/views/LoginView.vue +314 -0
  47. backend/_assets/frontend/src/views/SettingsView.vue +226 -0
  48. backend/_assets/frontend/src/views/UsersView.vue +235 -0
  49. backend/_assets/frontend/vite.config.js +55 -0
  50. backend/_assets/knowledge_data/compliance/regulations.json +383 -0
  51. backend/_assets/knowledge_data/cve/vulnerabilities.json +70720 -0
  52. backend/_assets/knowledge_data/mitre_attack/techniques.json +14097 -0
  53. backend/_assets/knowledge_data/remediation/remediation.json +574 -0
  54. backend/_assets/knowledge_data/threat_intel/actors.json +1602 -0
  55. backend/_assets/knowledge_data/threat_intel/malware.json +971 -0
  56. backend/agents/__init__.py +1 -0
  57. backend/agents/alert_filter/__init__.py +1 -0
  58. backend/agents/alert_filter/alert_filter_agent.py +88 -0
  59. backend/agents/analyst.py +331 -0
  60. backend/agents/base.py +553 -0
  61. backend/agents/classifier_agent.py +134 -0
  62. backend/agents/cot_engine.py +578 -0
  63. backend/agents/intel.py +46 -0
  64. backend/agents/knowledge/__init__.py +2 -0
  65. backend/agents/knowledge/agentic_rag.py +928 -0
  66. backend/agents/knowledge/knowledge_agent.py +47 -0
  67. backend/agents/registry.py +148 -0
  68. backend/agents/responder.py +51 -0
  69. backend/agents/summary_agent.py +219 -0
  70. backend/auto_ingestor.py +948 -0
  71. backend/auto_patrol.py +452 -0
  72. backend/cli_app.py +523 -0
  73. backend/config/__init__.py +20 -0
  74. backend/config/provider_profiles.py +416 -0
  75. backend/config/runtime_settings.py +84 -0
  76. backend/decision_fusion/__init__.py +32 -0
  77. backend/decision_fusion/base.py +127 -0
  78. backend/decision_fusion/dempster_shafer.py +419 -0
  79. backend/decision_fusion/weighted_average.py +141 -0
  80. backend/email_ingestor.py +588 -0
  81. backend/errors.py +243 -0
  82. backend/escalation.py +453 -0
  83. backend/evaluation/__init__.py +7 -0
  84. backend/evaluation/evaluator.py +197 -0
  85. backend/evaluation/metrics.py +210 -0
  86. backend/federation/__init__.py +22 -0
  87. backend/federation/core.py +743 -0
  88. backend/interface/__init__.py +1 -0
  89. backend/interface/api_server.py +1830 -0
  90. backend/interface/cli.py +671 -0
  91. backend/knowledge/__init__.py +11 -0
  92. backend/knowledge/compliance.py +101 -0
  93. backend/knowledge/cve_db.py +100 -0
  94. backend/knowledge/mitre_attack.py +457 -0
  95. backend/knowledge/remediation.py +84 -0
  96. backend/knowledge/threat_intel_kb.py +246 -0
  97. backend/llm/__init__.py +1 -0
  98. backend/llm/anthropic_compatible.py +191 -0
  99. backend/llm/base.py +81 -0
  100. backend/llm/deepseek.py +221 -0
  101. backend/llm/fallback.py +268 -0
  102. backend/llm/mock.py +204 -0
  103. backend/llm/openai_compatible.py +51 -0
  104. backend/llm/provider.py +217 -0
  105. backend/llm/qwen.py +218 -0
  106. backend/main.py +421 -0
  107. backend/memory/__init__.py +1 -0
  108. backend/memory/episodic_memory.py +132 -0
  109. backend/memory/manager.py +204 -0
  110. backend/memory/semantic_memory.py +156 -0
  111. backend/memory/session_memory.py +102 -0
  112. backend/ml_model/__init__.py +23 -0
  113. backend/ml_model/pipeline.py +191 -0
  114. backend/ml_model/trainer.py +627 -0
  115. backend/models/__init__.py +1 -0
  116. backend/models/event.py +62 -0
  117. backend/models/message.py +58 -0
  118. backend/models/output.py +1216 -0
  119. backend/monitoring/metrics.py +184 -0
  120. backend/orchestrator/__init__.py +17 -0
  121. backend/orchestrator/agent_router.py +145 -0
  122. backend/orchestrator/core.py +159 -0
  123. backend/orchestrator/dynamic_weights.py +287 -0
  124. backend/orchestrator/react_loop.py +1895 -0
  125. backend/orchestrator/risk_model.py +494 -0
  126. backend/orchestrator/risk_scorer.py +356 -0
  127. backend/reasoner/__init__.py +19 -0
  128. backend/reasoner/reasoner.py +316 -0
  129. backend/runtime_assets.py +45 -0
  130. backend/security/__init__.py +24 -0
  131. backend/security/adversarial/__init__.py +13 -0
  132. backend/security/adversarial/guard.py +121 -0
  133. backend/security/adversarial/redteam.py +121 -0
  134. backend/security/adversarial/scanner.py +208 -0
  135. backend/security/audit.py +118 -0
  136. backend/security/auth.py +283 -0
  137. backend/security/circuit_breaker.py +212 -0
  138. backend/security/dispatch.py +18 -0
  139. backend/security/ratelimit.py +237 -0
  140. backend/security/rbac.py +431 -0
  141. backend/security/refresh_sessions.py +193 -0
  142. backend/security/sanitizer.py +32 -0
  143. backend/storage/__init__.py +1 -0
  144. backend/storage/chroma_store.py +79 -0
  145. backend/storage/database.py +296 -0
  146. backend/storage/migrations/env.py +65 -0
  147. backend/storage/migrations/versions/0001_initial_schema.py +101 -0
  148. backend/storage/migrations/versions/0002_add_agent_logs_experiences.py +53 -0
  149. backend/storage/migrations/versions/0003_add_conversation_owner.py +30 -0
  150. backend/storage/models.py +194 -0
  151. backend/storage/repositories/__init__.py +1 -0
  152. backend/storage/repositories/asset_repo.py +86 -0
  153. backend/storage/repositories/conversation_repo.py +216 -0
  154. backend/storage/repositories/event_repo.py +76 -0
  155. backend/storage/repositories/trajectory_repo.py +324 -0
  156. backend/storage/retention.py +117 -0
  157. backend/tools/__init__.py +16 -0
  158. backend/tools/alert_filter.py +208 -0
  159. backend/tools/aliyun_firewall.py +237 -0
  160. backend/tools/aws_firewall.py +369 -0
  161. backend/tools/base.py +445 -0
  162. backend/tools/calling.py +207 -0
  163. backend/tools/cve_search.py +77 -0
  164. backend/tools/execution_engine.py +202 -0
  165. backend/tools/firewall.py +387 -0
  166. backend/tools/geoip.py +168 -0
  167. backend/tools/log_analyzer.py +185 -0
  168. backend/tools/ml_detector.py +226 -0
  169. backend/tools/registry.py +38 -0
  170. backend/tools/tencent_firewall.py +300 -0
  171. backend/tools/threat_intel.py +732 -0
  172. backend/tools/trust_checker.py +76 -0
  173. backend/utils/__init__.py +5 -0
  174. backend/utils/config_loader.py +164 -0
  175. backend/utils/env.py +75 -0
  176. backend/utils/logger.py +150 -0
  177. backend/utils/text.py +32 -0
  178. secagentx-3.0.0.dist-info/METADATA +325 -0
  179. secagentx-3.0.0.dist-info/RECORD +182 -0
  180. secagentx-3.0.0.dist-info/WHEEL +5 -0
  181. secagentx-3.0.0.dist-info/entry_points.txt +2 -0
  182. 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
@@ -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
+