@shirlytaylor73/smart-search 0.2.0-beta.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh-CN.md +180 -0
- package/npm/bin/smart-search.js +68 -0
- package/npm/scripts/postinstall.js +87 -0
- package/npm/scripts/resolve-prerelease-version.js +108 -0
- package/npm/scripts/set-package-version.js +35 -0
- package/npm/scripts/sync-python-version.js +22 -0
- package/npm/scripts/test-wrapper-repair.js +137 -0
- package/npm/scripts/test.js +76 -0
- package/package.json +42 -0
- package/pyproject.toml +36 -0
- package/skills/smart-search-cli/SKILL.md +41 -0
- package/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/skills/smart-search-cli/references/cli-core.md +5 -0
- package/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/skills/smart-search-cli/references/regression-release.md +28 -0
- package/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/__init__.py +1 -0
- package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
- package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/cli.py +3118 -0
- package/src/smart_search/config.py +809 -0
- package/src/smart_search/embedding_presets.py +40 -0
- package/src/smart_search/intent_router.py +757 -0
- package/src/smart_search/logger.py +43 -0
- package/src/smart_search/providers/__init__.py +20 -0
- package/src/smart_search/providers/base.py +41 -0
- package/src/smart_search/providers/context7.py +141 -0
- package/src/smart_search/providers/exa.py +206 -0
- package/src/smart_search/providers/jina.py +136 -0
- package/src/smart_search/providers/openai_compatible.py +541 -0
- package/src/smart_search/providers/xai_responses.py +117 -0
- package/src/smart_search/providers/zhipu.py +143 -0
- package/src/smart_search/providers/zhipu_mcp.py +230 -0
- package/src/smart_search/service.py +3445 -0
- package/src/smart_search/skill_installer.py +342 -0
- package/src/smart_search/sources.py +429 -0
- package/src/smart_search/utils.py +220 -0
|
@@ -0,0 +1,3445 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import config
|
|
11
|
+
from .intent_router import (
|
|
12
|
+
CAPABILITY_UTTERANCES,
|
|
13
|
+
CURRENT_INTENT_KEYWORDS as ROUTER_CURRENT_INTENT_KEYWORDS,
|
|
14
|
+
DEFAULT_ROUTE_CALIBRATION_MODELS,
|
|
15
|
+
DEFAULT_SEMANTIC_CONFIDENCE_MARGIN,
|
|
16
|
+
DEFAULT_SEMANTIC_CONFIDENCE_THRESHOLD,
|
|
17
|
+
DOCS_INTENT_KEYWORDS as ROUTER_DOCS_INTENT_KEYWORDS,
|
|
18
|
+
FETCH_INTENT_KEYWORDS as ROUTER_FETCH_INTENT_KEYWORDS,
|
|
19
|
+
ROUTABLE_CAPABILITIES,
|
|
20
|
+
ROUTE_CALIBRATION_QUERIES,
|
|
21
|
+
IntentRouter,
|
|
22
|
+
build_rules_route,
|
|
23
|
+
extract_urls as router_extract_urls,
|
|
24
|
+
_classifier_can_add_capability,
|
|
25
|
+
_cosine_similarity,
|
|
26
|
+
_ordered_capabilities,
|
|
27
|
+
_semantic_summary,
|
|
28
|
+
)
|
|
29
|
+
from .logger import log_info
|
|
30
|
+
from .providers.context7 import Context7Provider
|
|
31
|
+
from .providers.exa import ExaSearchProvider
|
|
32
|
+
from .providers.jina import JinaReaderProvider
|
|
33
|
+
from .providers.openai_compatible import OpenAICompatibleSearchProvider, get_local_time_info
|
|
34
|
+
from .providers.xai_responses import XAIResponsesSearchProvider
|
|
35
|
+
from .providers.zhipu import ZhipuWebSearchProvider
|
|
36
|
+
from .providers.zhipu_mcp import ZhipuMCPProvider
|
|
37
|
+
from .sources import merge_sources, new_session_id, split_answer_and_sources
|
|
38
|
+
from .utils import search_prompt
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_AVAILABLE_MODELS_CACHE: dict[tuple[str, str], list[str]] = {}
|
|
42
|
+
_AVAILABLE_MODELS_LOCK = asyncio.Lock()
|
|
43
|
+
SOURCE_PROVENANCE_WARNING = (
|
|
44
|
+
"extra_sources are retrieved in parallel and are not automatically used to verify generated content; "
|
|
45
|
+
"use fetch on key URLs for claim-level evidence."
|
|
46
|
+
)
|
|
47
|
+
MINIMUM_PROFILE_ERROR = (
|
|
48
|
+
"最低配置不满足:必须至少配置 main_search、docs_search、web_fetch 三类能力各一个 provider。"
|
|
49
|
+
)
|
|
50
|
+
OPENAI_COMPATIBLE_DIAGNOSE_COMMAND = "smart-search diagnose openai-compatible --format markdown"
|
|
51
|
+
DOCS_INTENT_KEYWORDS = ROUTER_DOCS_INTENT_KEYWORDS
|
|
52
|
+
ZH_CURRENT_KEYWORDS = ROUTER_CURRENT_INTENT_KEYWORDS
|
|
53
|
+
FETCH_INTENT_KEYWORDS = ROUTER_FETCH_INTENT_KEYWORDS
|
|
54
|
+
PROVIDER_PROFILES: dict[str, dict[str, Any]] = {
|
|
55
|
+
"xai-responses": {
|
|
56
|
+
"capability": "main_search",
|
|
57
|
+
"strengths": ["broad synthesis", "web_search", "x_search"],
|
|
58
|
+
"exclusions": ["evidence proof without fetch"],
|
|
59
|
+
"fallback_group": "main_search",
|
|
60
|
+
"minimum_profile_role": "main_search",
|
|
61
|
+
"quality_filters": ["source extraction required for high-risk claims"],
|
|
62
|
+
"route_reasons": ["broad live answer", "primary synthesis"],
|
|
63
|
+
},
|
|
64
|
+
"openai-compatible": {
|
|
65
|
+
"capability": "main_search",
|
|
66
|
+
"strengths": ["broad synthesis", "relay compatibility"],
|
|
67
|
+
"exclusions": ["xAI server tools"],
|
|
68
|
+
"fallback_group": "main_search",
|
|
69
|
+
"minimum_profile_role": "main_search",
|
|
70
|
+
"quality_filters": ["source extraction required for high-risk claims"],
|
|
71
|
+
"route_reasons": ["relay-compatible primary synthesis"],
|
|
72
|
+
},
|
|
73
|
+
"context7": {
|
|
74
|
+
"capability": "docs_search",
|
|
75
|
+
"strengths": ["library docs", "API docs", "framework docs", "versioned snippets"],
|
|
76
|
+
"exclusions": ["general news", "generic web facts"],
|
|
77
|
+
"fallback_group": "docs_search",
|
|
78
|
+
"minimum_profile_role": "docs_search",
|
|
79
|
+
"quality_filters": ["library id required", "content required before citation"],
|
|
80
|
+
"route_reasons": ["docs/API evidence", "framework reference"],
|
|
81
|
+
},
|
|
82
|
+
"exa": {
|
|
83
|
+
"capability": "docs_search",
|
|
84
|
+
"strengths": ["official domains", "papers", "product pages", "trusted low-noise discovery", "similar pages"],
|
|
85
|
+
"exclusions": ["default second hop for every high-risk claim"],
|
|
86
|
+
"fallback_group": "docs_search",
|
|
87
|
+
"minimum_profile_role": "docs_search",
|
|
88
|
+
"quality_filters": ["URL required", "fetch before proof citation"],
|
|
89
|
+
"route_reasons": ["official low-noise discovery", "paper/product discovery"],
|
|
90
|
+
},
|
|
91
|
+
"zhipu": {
|
|
92
|
+
"capability": "web_search",
|
|
93
|
+
"strengths": ["Chinese", "domestic China", "current", "policy", "announcements", "recency filters"],
|
|
94
|
+
"exclusions": ["web_fetch", "chat model selection"],
|
|
95
|
+
"fallback_group": "web_search",
|
|
96
|
+
"minimum_profile_role": "",
|
|
97
|
+
"quality_filters": ["URL required", "fetch before proof citation"],
|
|
98
|
+
"route_reasons": ["Chinese/current/policy discovery"],
|
|
99
|
+
},
|
|
100
|
+
"zhipu-mcp": {
|
|
101
|
+
"capability": "web_search",
|
|
102
|
+
"strengths": ["Coding Plan quota", "remote MCP web_search_prime"],
|
|
103
|
+
"exclusions": ["Zhipu REST Web Search API"],
|
|
104
|
+
"fallback_group": "web_search",
|
|
105
|
+
"minimum_profile_role": "",
|
|
106
|
+
"quality_filters": ["URL required", "fetch before proof citation"],
|
|
107
|
+
"route_reasons": ["Coding Plan quota web discovery"],
|
|
108
|
+
},
|
|
109
|
+
"tavily": {
|
|
110
|
+
"capability": "web_search",
|
|
111
|
+
"capabilities": ["web_search", "web_fetch", "site_map"],
|
|
112
|
+
"strengths": ["broad source discovery", "site map", "URL extract"],
|
|
113
|
+
"exclusions": ["docs semantic replacement"],
|
|
114
|
+
"fallback_group": "web_search/web_fetch/site_map",
|
|
115
|
+
"minimum_profile_role": "web_fetch",
|
|
116
|
+
"quality_filters": ["non-empty normalized result", "non-empty extracted content"],
|
|
117
|
+
"route_reasons": ["broad source discovery", "site map", "URL fetch"],
|
|
118
|
+
},
|
|
119
|
+
"jina": {
|
|
120
|
+
"capability": "web_fetch",
|
|
121
|
+
"strengths": ["known public URL", "PDF", "arXiv", "clean markdown", "ReaderLM-v2 with key"],
|
|
122
|
+
"exclusions": ["general search provider", "anonymous standard minimum profile"],
|
|
123
|
+
"fallback_group": "web_fetch",
|
|
124
|
+
"minimum_profile_role": "web_fetch_with_key",
|
|
125
|
+
"quality_filters": ["non-empty markdown", "challenge page rejection", "ReaderLM-v2 requires key"],
|
|
126
|
+
"route_reasons": ["known URL extraction", "PDF/arXiv extraction"],
|
|
127
|
+
},
|
|
128
|
+
"zhipu-mcp-reader": {
|
|
129
|
+
"capability": "web_fetch",
|
|
130
|
+
"strengths": ["Coding Plan quota", "remote MCP webReader"],
|
|
131
|
+
"exclusions": ["Zhipu REST Web Search API"],
|
|
132
|
+
"fallback_group": "web_fetch",
|
|
133
|
+
"minimum_profile_role": "",
|
|
134
|
+
"quality_filters": ["non-empty reader content"],
|
|
135
|
+
"route_reasons": ["Coding Plan quota page read"],
|
|
136
|
+
},
|
|
137
|
+
"firecrawl": {
|
|
138
|
+
"capability": "web_fetch",
|
|
139
|
+
"capabilities": ["web_search", "web_fetch"],
|
|
140
|
+
"strengths": ["robust scrape fallback", "JS-heavy pages", "dynamic pages", "OCR/PDF/structured extraction"],
|
|
141
|
+
"exclusions": ["docs semantic replacement"],
|
|
142
|
+
"fallback_group": "web_search/web_fetch",
|
|
143
|
+
"minimum_profile_role": "web_fetch",
|
|
144
|
+
"quality_filters": ["non-empty normalized result", "non-empty extracted content"],
|
|
145
|
+
"route_reasons": ["JS-heavy fetch", "dynamic/browser-like extraction", "robust fetch fallback"],
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
MAIN_SEARCH_FALLBACK_CHAIN = ["xai-responses", "openai-compatible"]
|
|
149
|
+
OPERATION_PROFILES: dict[str, dict[str, Any]] = {
|
|
150
|
+
"search.answer": {"providers": ["xai-responses", "openai-compatible"], "features": set()},
|
|
151
|
+
"search.sources": {"providers": ["exa", "zhipu", "zhipu-mcp", "tavily", "firecrawl"], "features": set()},
|
|
152
|
+
"search.similar": {"providers": ["exa"], "features": set()},
|
|
153
|
+
"docs.resolve": {"providers": ["context7"], "features": set()},
|
|
154
|
+
"docs.search": {"providers": ["context7", "exa", "zhipu-mcp-zread"], "features": set()},
|
|
155
|
+
"docs.tree": {"providers": ["zhipu-mcp-zread"], "features": set()},
|
|
156
|
+
"docs.read": {"providers": ["zhipu-mcp-zread"], "features": set()},
|
|
157
|
+
"fetch.content": {"providers": ["tavily", "jina", "zhipu-mcp-reader", "firecrawl"], "features": set()},
|
|
158
|
+
"fetch.extract": {"providers": ["firecrawl"], "features": {"structured", "max_length"}},
|
|
159
|
+
"map.site": {"providers": ["tavily"], "features": {"instructions", "max_depth", "max_breadth", "limit", "timeout"}},
|
|
160
|
+
}
|
|
161
|
+
PROVIDER_OPERATION_FEATURES: dict[str, dict[str, set[str]]] = {
|
|
162
|
+
"exa": {
|
|
163
|
+
"search.sources": {"semantic", "keyword", "auto", "time", "domains", "category", "text", "highlights", "limit"},
|
|
164
|
+
"search.similar": {"limit"},
|
|
165
|
+
"docs.search": {"domains", "highlights", "limit"},
|
|
166
|
+
},
|
|
167
|
+
"zhipu": {"search.sources": {"time", "domains", "limit"}},
|
|
168
|
+
"zhipu-mcp": {"search.sources": {"limit"}},
|
|
169
|
+
"tavily": {"search.sources": {"limit"}, "fetch.content": set(), "map.site": OPERATION_PROFILES["map.site"]["features"]},
|
|
170
|
+
"firecrawl": {"search.sources": {"limit"}, "fetch.content": set(), "fetch.extract": {"structured", "max_length"}},
|
|
171
|
+
"context7": {"docs.resolve": set(), "docs.search": set()},
|
|
172
|
+
"zhipu-mcp-zread": {"docs.search": set(), "docs.tree": {"path", "ref"}, "docs.read": {"ref"}},
|
|
173
|
+
"jina": {"fetch.content": set()},
|
|
174
|
+
"zhipu-mcp-reader": {"fetch.content": set()},
|
|
175
|
+
"xai-responses": {"search.answer": set()},
|
|
176
|
+
"openai-compatible": {"search.answer": {"stream"}},
|
|
177
|
+
}
|
|
178
|
+
MAIN_SEARCH_PROVIDER_ALIASES = {
|
|
179
|
+
"xai-responses": {"xai-responses", "xai", "grok", "grok-web-tools"},
|
|
180
|
+
"openai-compatible": {"openai-compatible", "openai", "chat-completions", "primary"},
|
|
181
|
+
}
|
|
182
|
+
MODEL_BREAKER_FAILURE_THRESHOLD = 2
|
|
183
|
+
MODEL_BREAKER_COOLDOWN_SECONDS = 600.0
|
|
184
|
+
_OPENAI_COMPATIBLE_MODEL_BREAKERS: dict[tuple[str, str], dict[str, Any]] = {}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _elapsed_ms(start: float) -> float:
|
|
188
|
+
return round((time.time() - start) * 1000, 2)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _normalize_domain_filter(value: str | list[str] | tuple[str, ...] | None) -> list[str] | None:
|
|
192
|
+
if not value:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
raw_parts = [value] if isinstance(value, str) else [str(item) for item in value if item]
|
|
196
|
+
domains: list[str] = []
|
|
197
|
+
for part in raw_parts:
|
|
198
|
+
domains.extend(item.strip() for item in re.split(r"[\s,]+", part) if item.strip())
|
|
199
|
+
return domains or None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _empty_search_result(
|
|
203
|
+
start: float,
|
|
204
|
+
session_id: str,
|
|
205
|
+
query: str,
|
|
206
|
+
error_type: str,
|
|
207
|
+
error: str,
|
|
208
|
+
primary_api_mode: str = "",
|
|
209
|
+
extra: dict[str, Any] | None = None,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
data: dict[str, Any] = {
|
|
212
|
+
"ok": False,
|
|
213
|
+
"error_type": error_type,
|
|
214
|
+
"error": error,
|
|
215
|
+
"session_id": session_id,
|
|
216
|
+
"query": query,
|
|
217
|
+
"primary_api_mode": primary_api_mode,
|
|
218
|
+
"content": "",
|
|
219
|
+
"sources": [],
|
|
220
|
+
"sources_count": 0,
|
|
221
|
+
"primary_sources": [],
|
|
222
|
+
"primary_sources_count": 0,
|
|
223
|
+
"extra_sources": [],
|
|
224
|
+
"extra_sources_count": 0,
|
|
225
|
+
"source_warning": "",
|
|
226
|
+
"routing_decision": {},
|
|
227
|
+
"providers_used": [],
|
|
228
|
+
"provider_attempts": [],
|
|
229
|
+
"fallback_used": False,
|
|
230
|
+
"validation_level": "",
|
|
231
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
232
|
+
}
|
|
233
|
+
if extra:
|
|
234
|
+
data.update(extra)
|
|
235
|
+
return data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _attempt(
|
|
239
|
+
capability: str,
|
|
240
|
+
provider: str,
|
|
241
|
+
status: str,
|
|
242
|
+
start: float,
|
|
243
|
+
result_count: int = 0,
|
|
244
|
+
error_type: str = "",
|
|
245
|
+
error: str = "",
|
|
246
|
+
extra: dict[str, Any] | None = None,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
data = {
|
|
249
|
+
"capability": capability,
|
|
250
|
+
"provider": provider,
|
|
251
|
+
"status": status,
|
|
252
|
+
"error_type": error_type,
|
|
253
|
+
"error": error,
|
|
254
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
255
|
+
"result_count": result_count,
|
|
256
|
+
}
|
|
257
|
+
if extra:
|
|
258
|
+
data.update(extra)
|
|
259
|
+
return data
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _openai_model_breaker_key(api_url: str, model: str) -> tuple[str, str]:
|
|
263
|
+
return (api_url.rstrip("/"), model)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def reset_runtime_breakers() -> None:
|
|
267
|
+
_OPENAI_COMPATIBLE_MODEL_BREAKERS.clear()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _openai_model_breaker_state(api_url: str, model: str) -> dict[str, Any]:
|
|
271
|
+
key = _openai_model_breaker_key(api_url, model)
|
|
272
|
+
state = _OPENAI_COMPATIBLE_MODEL_BREAKERS.get(key, {})
|
|
273
|
+
opened_until = float(state.get("opened_until") or 0.0)
|
|
274
|
+
now = time.monotonic()
|
|
275
|
+
if opened_until and opened_until > now:
|
|
276
|
+
return {
|
|
277
|
+
"state": "open",
|
|
278
|
+
"opened_until_seconds": round(opened_until - now, 3),
|
|
279
|
+
"consecutive_failures": int(state.get("consecutive_failures") or 0),
|
|
280
|
+
}
|
|
281
|
+
if opened_until and opened_until <= now:
|
|
282
|
+
_OPENAI_COMPATIBLE_MODEL_BREAKERS.pop(key, None)
|
|
283
|
+
state = {}
|
|
284
|
+
return {"state": "closed", "consecutive_failures": int(state.get("consecutive_failures") or 0)}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _record_openai_model_success(api_url: str, model: str) -> None:
|
|
288
|
+
_OPENAI_COMPATIBLE_MODEL_BREAKERS.pop(_openai_model_breaker_key(api_url, model), None)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _record_openai_model_failure(api_url: str, model: str) -> dict[str, Any]:
|
|
292
|
+
key = _openai_model_breaker_key(api_url, model)
|
|
293
|
+
state = _OPENAI_COMPATIBLE_MODEL_BREAKERS.setdefault(key, {"consecutive_failures": 0, "opened_until": 0.0})
|
|
294
|
+
state["consecutive_failures"] = int(state.get("consecutive_failures") or 0) + 1
|
|
295
|
+
if state["consecutive_failures"] >= MODEL_BREAKER_FAILURE_THRESHOLD:
|
|
296
|
+
state["opened_until"] = time.monotonic() + MODEL_BREAKER_COOLDOWN_SECONDS
|
|
297
|
+
return _openai_model_breaker_state(api_url, model)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _openai_model_candidates(provider_config: dict[str, Any], *, fallback_mode: str, model_override: str) -> list[dict[str, Any]]:
|
|
301
|
+
primary_model = provider_config["model"]
|
|
302
|
+
candidates = [
|
|
303
|
+
{
|
|
304
|
+
**provider_config,
|
|
305
|
+
"model": primary_model,
|
|
306
|
+
"model_role": "primary",
|
|
307
|
+
"fallback_from_model": "",
|
|
308
|
+
"stream": provider_config.get("stream", False),
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
if fallback_mode == "off" or model_override:
|
|
312
|
+
return candidates
|
|
313
|
+
for fallback_model in provider_config.get("fallback_models") or []:
|
|
314
|
+
candidates.append(
|
|
315
|
+
{
|
|
316
|
+
**provider_config,
|
|
317
|
+
"model": fallback_model,
|
|
318
|
+
"model_role": "fallback",
|
|
319
|
+
"fallback_from_model": primary_model,
|
|
320
|
+
"stream": False,
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
return candidates
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _remaining_budget_seconds(start: float, timeout_seconds: float | None) -> float | None:
|
|
327
|
+
if timeout_seconds is None:
|
|
328
|
+
return None
|
|
329
|
+
return max(0.0, float(timeout_seconds) - (time.time() - start))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _attempt_timeout_seconds(
|
|
333
|
+
search_start: float,
|
|
334
|
+
timeout_seconds: float | None,
|
|
335
|
+
remaining_candidates: int,
|
|
336
|
+
) -> float | None:
|
|
337
|
+
remaining_budget = _remaining_budget_seconds(search_start, timeout_seconds)
|
|
338
|
+
if remaining_budget is None:
|
|
339
|
+
return None
|
|
340
|
+
if remaining_budget <= 0:
|
|
341
|
+
return 0.001
|
|
342
|
+
if remaining_candidates <= 1:
|
|
343
|
+
return max(0.001, remaining_budget)
|
|
344
|
+
return max(0.001, min(30.0, remaining_budget / 2.0))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _append_openai_transport_attempts(
|
|
348
|
+
provider_attempts: list[dict],
|
|
349
|
+
search_provider: Any,
|
|
350
|
+
candidate_config: dict[str, Any],
|
|
351
|
+
) -> bool:
|
|
352
|
+
transport_attempts = getattr(search_provider, "last_transport_attempts", [])
|
|
353
|
+
if candidate_config.get("provider") != "openai-compatible" or not transport_attempts:
|
|
354
|
+
return False
|
|
355
|
+
for transport_attempt in transport_attempts:
|
|
356
|
+
transport_extra = {
|
|
357
|
+
key: value
|
|
358
|
+
for key, value in transport_attempt.items()
|
|
359
|
+
if key not in {"status", "error_type", "error", "elapsed_ms", "result_count"}
|
|
360
|
+
}
|
|
361
|
+
if candidate_config.get("fallback_from_model"):
|
|
362
|
+
transport_extra["fallback_from_model"] = candidate_config["fallback_from_model"]
|
|
363
|
+
provider_attempts.append(
|
|
364
|
+
{
|
|
365
|
+
**_attempt(
|
|
366
|
+
"main_search",
|
|
367
|
+
search_provider.get_provider_name(),
|
|
368
|
+
transport_attempt.get("status", "error"),
|
|
369
|
+
time.time(),
|
|
370
|
+
result_count=int(transport_attempt.get("result_count") or 0),
|
|
371
|
+
error_type=transport_attempt.get("error_type", ""),
|
|
372
|
+
error=transport_attempt.get("error", ""),
|
|
373
|
+
),
|
|
374
|
+
"elapsed_ms": transport_attempt.get("elapsed_ms", 0),
|
|
375
|
+
**transport_extra,
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _normalize_source_results(results: list[dict] | None, provider: str) -> list[dict]:
|
|
382
|
+
normalized: list[dict] = []
|
|
383
|
+
for item in results or []:
|
|
384
|
+
url = (item.get("url") or item.get("link") or "").strip()
|
|
385
|
+
if not url:
|
|
386
|
+
continue
|
|
387
|
+
out = {"url": url, "provider": item.get("provider") or provider}
|
|
388
|
+
title = (item.get("title") or "").strip()
|
|
389
|
+
if title:
|
|
390
|
+
out["title"] = title
|
|
391
|
+
desc = (item.get("description") or item.get("content") or item.get("snippet") or "").strip()
|
|
392
|
+
if desc:
|
|
393
|
+
out["description"] = desc
|
|
394
|
+
out["snippet"] = desc
|
|
395
|
+
published = item.get("published_date") or item.get("publishedDate") or item.get("publish_date")
|
|
396
|
+
if published:
|
|
397
|
+
out["published_date"] = published
|
|
398
|
+
source = item.get("source") or item.get("media")
|
|
399
|
+
if source:
|
|
400
|
+
out["source"] = source
|
|
401
|
+
normalized.append(out)
|
|
402
|
+
return normalized
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _provider_names_from_attempts(attempts: list[dict]) -> list[str]:
|
|
406
|
+
names: list[str] = []
|
|
407
|
+
for attempt in attempts:
|
|
408
|
+
provider = attempt.get("provider")
|
|
409
|
+
if attempt.get("status") == "ok" and provider and provider not in names:
|
|
410
|
+
names.append(provider)
|
|
411
|
+
return names
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _fallback_used(attempts: list[dict]) -> bool:
|
|
415
|
+
by_capability: dict[str, list[dict]] = {}
|
|
416
|
+
for attempt in attempts:
|
|
417
|
+
capability = attempt.get("capability", "")
|
|
418
|
+
if attempt.get("status") in {"ok", "empty", "error", "skipped"}:
|
|
419
|
+
by_capability.setdefault(capability, []).append(attempt)
|
|
420
|
+
for capability_attempts in by_capability.values():
|
|
421
|
+
previous_failed = False
|
|
422
|
+
previous_identity = ""
|
|
423
|
+
for attempt in capability_attempts:
|
|
424
|
+
provider = attempt.get("provider", "")
|
|
425
|
+
model = str(attempt.get("model") or "")
|
|
426
|
+
identity = f"{provider}:{model}" if provider == "OpenAI-compatible" and model else provider
|
|
427
|
+
status = attempt.get("status")
|
|
428
|
+
if previous_identity and identity and identity != previous_identity:
|
|
429
|
+
return True
|
|
430
|
+
if previous_failed and identity != previous_identity:
|
|
431
|
+
return True
|
|
432
|
+
previous_failed = status in {"empty", "error", "skipped"}
|
|
433
|
+
previous_identity = identity or previous_identity
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def provider_profiles() -> dict[str, dict[str, Any]]:
|
|
438
|
+
profiles = {provider: dict(profile) for provider, profile in PROVIDER_PROFILES.items()}
|
|
439
|
+
for provider, profile in profiles.items():
|
|
440
|
+
operations = PROVIDER_OPERATION_FEATURES.get(provider, {})
|
|
441
|
+
profile["operations"] = sorted(operations)
|
|
442
|
+
profile["operation_features"] = {
|
|
443
|
+
operation: sorted(features)
|
|
444
|
+
for operation, features in operations.items()
|
|
445
|
+
}
|
|
446
|
+
return profiles
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def operation_profiles() -> dict[str, dict[str, Any]]:
|
|
450
|
+
return {
|
|
451
|
+
operation: {
|
|
452
|
+
"providers": list(profile["providers"]),
|
|
453
|
+
"features": sorted(profile.get("features", set())),
|
|
454
|
+
}
|
|
455
|
+
for operation, profile in OPERATION_PROFILES.items()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _operation_provider_configured(provider: str) -> bool:
|
|
460
|
+
if provider == "zhipu-mcp-zread":
|
|
461
|
+
return bool(config.zhipu_mcp_api_key)
|
|
462
|
+
return _provider_configured(provider)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def operation_policy(operation: str) -> dict[str, Any]:
|
|
466
|
+
raw = config.operation_config.get(operation, {})
|
|
467
|
+
fallback_value = raw.get("fallback", True)
|
|
468
|
+
fallback = str(fallback_value).strip().lower() not in {"false", "0", "off", "no"}
|
|
469
|
+
try:
|
|
470
|
+
timeout = float(raw.get("timeout", 90.0))
|
|
471
|
+
except (TypeError, ValueError):
|
|
472
|
+
timeout = 90.0
|
|
473
|
+
if timeout <= 0:
|
|
474
|
+
timeout = 90.0
|
|
475
|
+
return {"fallback": fallback, "timeout": timeout}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def operation_candidates(
|
|
479
|
+
operation: str,
|
|
480
|
+
*,
|
|
481
|
+
required_features: set[str] | None = None,
|
|
482
|
+
) -> tuple[list[str], set[str]]:
|
|
483
|
+
profile = OPERATION_PROFILES.get(operation)
|
|
484
|
+
if not profile:
|
|
485
|
+
return [], set(required_features or set())
|
|
486
|
+
operation_config = config.operation_config.get(operation, {})
|
|
487
|
+
configured_order = operation_config.get("providers")
|
|
488
|
+
providers = list(configured_order) if isinstance(configured_order, list) else list(profile["providers"])
|
|
489
|
+
disabled = set(operation_config.get("disabled") or [])
|
|
490
|
+
required = set(required_features or set())
|
|
491
|
+
candidates: list[str] = []
|
|
492
|
+
eligible: list[str] = []
|
|
493
|
+
supported_features: set[str] = set()
|
|
494
|
+
for provider in providers:
|
|
495
|
+
provider = str(provider).strip().lower()
|
|
496
|
+
if provider in disabled or provider not in profile["providers"] or not _operation_provider_configured(provider):
|
|
497
|
+
continue
|
|
498
|
+
eligible.append(provider)
|
|
499
|
+
supported = PROVIDER_OPERATION_FEATURES.get(provider, {}).get(operation, set())
|
|
500
|
+
supported_features.update(supported)
|
|
501
|
+
if required.issubset(supported):
|
|
502
|
+
candidates.append(provider)
|
|
503
|
+
if not operation_policy(operation)["fallback"]:
|
|
504
|
+
candidates = candidates[:1]
|
|
505
|
+
missing = required - supported_features if eligible and not candidates else set()
|
|
506
|
+
return candidates, missing
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def _await_operation(awaitable: Any, *, start: float, timeout: float) -> Any:
|
|
510
|
+
remaining = timeout - (time.time() - start)
|
|
511
|
+
if remaining <= 0:
|
|
512
|
+
raise asyncio.TimeoutError("operation timeout budget exhausted")
|
|
513
|
+
return await asyncio.wait_for(awaitable, timeout=remaining)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def _run_operation_candidates(
|
|
517
|
+
operation: str,
|
|
518
|
+
candidates: list[str],
|
|
519
|
+
invoke: Any,
|
|
520
|
+
*,
|
|
521
|
+
start: float,
|
|
522
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
523
|
+
attempts: list[dict[str, Any]] = []
|
|
524
|
+
timeout = operation_policy(operation)["timeout"]
|
|
525
|
+
for provider in candidates:
|
|
526
|
+
attempt_start = time.time()
|
|
527
|
+
try:
|
|
528
|
+
data = await _await_operation(invoke(provider), start=start, timeout=timeout)
|
|
529
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError) as exc:
|
|
530
|
+
attempts.append(_attempt(operation, provider, "error", attempt_start, error_type="timeout", error=str(exc)))
|
|
531
|
+
continue
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
attempts.append(_attempt(operation, provider, "error", attempt_start, error_type="network_error", error=str(exc)))
|
|
534
|
+
continue
|
|
535
|
+
if data.get("ok"):
|
|
536
|
+
count = len(data.get("results") or data.get("entries") or []) or 1
|
|
537
|
+
attempts.append(_attempt(operation, provider, "ok", attempt_start, result_count=count))
|
|
538
|
+
return data, attempts
|
|
539
|
+
error_type = str(data.get("error_type") or "")
|
|
540
|
+
attempts.append(
|
|
541
|
+
_attempt(
|
|
542
|
+
operation,
|
|
543
|
+
provider,
|
|
544
|
+
"error" if error_type else "empty",
|
|
545
|
+
attempt_start,
|
|
546
|
+
error_type=error_type,
|
|
547
|
+
error=str(data.get("error") or ""),
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
if error_type == "parameter_error":
|
|
551
|
+
return data, attempts
|
|
552
|
+
return None, attempts
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _operation_envelope(
|
|
556
|
+
capability: str,
|
|
557
|
+
operation: str,
|
|
558
|
+
*,
|
|
559
|
+
ok: bool,
|
|
560
|
+
start: float,
|
|
561
|
+
content: str = "",
|
|
562
|
+
sources: list[dict[str, Any]] | None = None,
|
|
563
|
+
error_type: str = "",
|
|
564
|
+
error: str = "",
|
|
565
|
+
extra: dict[str, Any] | None = None,
|
|
566
|
+
debug: bool = False,
|
|
567
|
+
) -> dict[str, Any]:
|
|
568
|
+
data: dict[str, Any] = {
|
|
569
|
+
"ok": ok,
|
|
570
|
+
"capability": capability,
|
|
571
|
+
"operation": operation,
|
|
572
|
+
"content": content,
|
|
573
|
+
"sources": sources or [],
|
|
574
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
575
|
+
}
|
|
576
|
+
if not ok:
|
|
577
|
+
data.update({"error_type": error_type or "runtime_error", "error": error or "operation failed"})
|
|
578
|
+
if extra:
|
|
579
|
+
data.update(extra)
|
|
580
|
+
if not debug:
|
|
581
|
+
for key in ("provider_attempts", "providers_used", "routing_decision", "capability_status", "minimum_profile_ok", "fallback_used", "transport_fallback_used", "model_fallback_used"):
|
|
582
|
+
data.pop(key, None)
|
|
583
|
+
return data
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def intent_router_status() -> dict[str, Any]:
|
|
587
|
+
return IntentRouter(config).status()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _provider_supports_capability(provider: str, capability: str) -> bool:
|
|
591
|
+
profile = PROVIDER_PROFILES.get(provider, {})
|
|
592
|
+
capabilities = set(profile.get("capabilities") or [profile.get("capability", "")])
|
|
593
|
+
return capability in capabilities
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _provider_configured(provider: str) -> bool:
|
|
597
|
+
if provider == "xai-responses":
|
|
598
|
+
return bool(config.xai_api_key)
|
|
599
|
+
if provider == "openai-compatible":
|
|
600
|
+
return bool(config.openai_compatible_api_url and config.openai_compatible_api_key)
|
|
601
|
+
if provider == "context7":
|
|
602
|
+
return bool(config.context7_api_key)
|
|
603
|
+
if provider == "exa":
|
|
604
|
+
return bool(config.exa_api_key)
|
|
605
|
+
if provider == "zhipu":
|
|
606
|
+
return bool(config.zhipu_api_key)
|
|
607
|
+
if provider == "zhipu-mcp":
|
|
608
|
+
return bool(config.zhipu_mcp_api_key)
|
|
609
|
+
if provider == "tavily":
|
|
610
|
+
return bool(config.tavily_api_key)
|
|
611
|
+
if provider == "jina":
|
|
612
|
+
return bool(config.jina_api_key)
|
|
613
|
+
if provider == "zhipu-mcp-reader":
|
|
614
|
+
return bool(config.zhipu_mcp_api_key)
|
|
615
|
+
if provider == "firecrawl":
|
|
616
|
+
return bool(config.firecrawl_api_key)
|
|
617
|
+
if provider == "main-search":
|
|
618
|
+
return bool(config.xai_api_key or (config.openai_compatible_api_url and config.openai_compatible_api_key))
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _extract_urls(query: str) -> list[str]:
|
|
623
|
+
return router_extract_urls(query)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def get_capability_status() -> dict[str, Any]:
|
|
627
|
+
main_configured = _configured_main_search_provider_ids()
|
|
628
|
+
status = {
|
|
629
|
+
"main_search": {
|
|
630
|
+
"configured": main_configured,
|
|
631
|
+
"fallback_chain": MAIN_SEARCH_FALLBACK_CHAIN,
|
|
632
|
+
"ok": bool(main_configured),
|
|
633
|
+
},
|
|
634
|
+
"web_search": {
|
|
635
|
+
"configured": [
|
|
636
|
+
name
|
|
637
|
+
for name, enabled in [
|
|
638
|
+
("zhipu", bool(config.zhipu_api_key)),
|
|
639
|
+
("zhipu-mcp", bool(config.zhipu_mcp_api_key)),
|
|
640
|
+
("tavily", bool(config.tavily_api_key)),
|
|
641
|
+
("firecrawl", bool(config.firecrawl_api_key)),
|
|
642
|
+
]
|
|
643
|
+
if enabled
|
|
644
|
+
],
|
|
645
|
+
"fallback_chain": ["zhipu", "zhipu-mcp", "tavily", "firecrawl"],
|
|
646
|
+
},
|
|
647
|
+
"docs_search": {
|
|
648
|
+
"configured": [
|
|
649
|
+
name
|
|
650
|
+
for name, enabled in [
|
|
651
|
+
("context7", bool(config.context7_api_key)),
|
|
652
|
+
("exa", bool(config.exa_api_key)),
|
|
653
|
+
]
|
|
654
|
+
if enabled
|
|
655
|
+
],
|
|
656
|
+
"fallback_chain": ["context7", "exa"],
|
|
657
|
+
},
|
|
658
|
+
"web_fetch": {
|
|
659
|
+
"configured": [
|
|
660
|
+
name
|
|
661
|
+
for name, enabled in [
|
|
662
|
+
("tavily", bool(config.tavily_api_key)),
|
|
663
|
+
("jina", bool(config.jina_api_key)),
|
|
664
|
+
("zhipu-mcp-reader", bool(config.zhipu_mcp_api_key)),
|
|
665
|
+
("firecrawl", bool(config.firecrawl_api_key)),
|
|
666
|
+
]
|
|
667
|
+
if enabled
|
|
668
|
+
],
|
|
669
|
+
"fallback_chain": ["tavily", "jina", "zhipu-mcp-reader", "firecrawl"],
|
|
670
|
+
},
|
|
671
|
+
}
|
|
672
|
+
for capability in ("web_search", "docs_search", "web_fetch"):
|
|
673
|
+
status[capability]["ok"] = bool(status[capability]["configured"])
|
|
674
|
+
return status
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _minimum_profile_result(profile: str, capability_status: dict[str, Any]) -> dict[str, Any]:
|
|
678
|
+
required = [] if profile == "off" else ["main_search", "docs_search", "web_fetch"]
|
|
679
|
+
missing = [capability for capability in required if not capability_status.get(capability, {}).get("ok")]
|
|
680
|
+
return {
|
|
681
|
+
"ok": not missing,
|
|
682
|
+
"error_type": "config_error" if missing else "",
|
|
683
|
+
"error": f"{MINIMUM_PROFILE_ERROR} 缺失能力: {', '.join(missing)}" if missing else "",
|
|
684
|
+
"profile": profile,
|
|
685
|
+
"required": required,
|
|
686
|
+
"missing": missing,
|
|
687
|
+
"capability_status": capability_status,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def validate_minimum_profile() -> dict[str, Any]:
|
|
692
|
+
try:
|
|
693
|
+
profile = config.minimum_profile
|
|
694
|
+
except ValueError as e:
|
|
695
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "missing": []}
|
|
696
|
+
return _minimum_profile_result(profile, get_capability_status())
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _parse_provider_filter(providers: str = "auto") -> set[str] | None:
|
|
700
|
+
if not providers or providers.strip().lower() == "auto":
|
|
701
|
+
return None
|
|
702
|
+
return {item.strip().lower() for item in providers.split(",") if item.strip()}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _provider_allowed(provider_id: str, provider_filter: set[str] | None) -> bool:
|
|
706
|
+
if provider_filter is None:
|
|
707
|
+
return True
|
|
708
|
+
aliases = MAIN_SEARCH_PROVIDER_ALIASES.get(provider_id, {provider_id})
|
|
709
|
+
return bool(provider_filter.intersection(aliases))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _configured_main_search_provider_ids() -> list[str]:
|
|
713
|
+
configured: set[str] = set()
|
|
714
|
+
|
|
715
|
+
if config.xai_api_key:
|
|
716
|
+
configured.add("xai-responses")
|
|
717
|
+
if config.openai_compatible_api_url and config.openai_compatible_api_key:
|
|
718
|
+
configured.add("openai-compatible")
|
|
719
|
+
|
|
720
|
+
return [provider for provider in MAIN_SEARCH_FALLBACK_CHAIN if provider in configured]
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _main_search_provider_configs(model_override: str = "", providers: str = "auto") -> list[dict[str, Any]]:
|
|
724
|
+
provider_filter = _parse_provider_filter(providers)
|
|
725
|
+
by_provider: dict[str, dict[str, Any]] = {}
|
|
726
|
+
|
|
727
|
+
if config.xai_api_key:
|
|
728
|
+
by_provider["xai-responses"] = {
|
|
729
|
+
"provider": "xai-responses",
|
|
730
|
+
"mode": "xai-responses",
|
|
731
|
+
"api_url": config.xai_api_url,
|
|
732
|
+
"api_key": config.xai_api_key,
|
|
733
|
+
"model": model_override or config.xai_model,
|
|
734
|
+
"tools": config.parse_xai_tools(config.xai_tools_raw),
|
|
735
|
+
"source": "XAI_*",
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if config.openai_compatible_api_url and config.openai_compatible_api_key:
|
|
739
|
+
by_provider["openai-compatible"] = {
|
|
740
|
+
"provider": "openai-compatible",
|
|
741
|
+
"mode": "chat-completions",
|
|
742
|
+
"api_url": config.openai_compatible_api_url,
|
|
743
|
+
"api_key": config.openai_compatible_api_key,
|
|
744
|
+
"model": model_override or config.openai_compatible_model,
|
|
745
|
+
"fallback_models": [] if model_override else config.openai_compatible_fallback_models,
|
|
746
|
+
"stream": config.openai_compatible_stream,
|
|
747
|
+
"tools": [],
|
|
748
|
+
"source": "OPENAI_COMPATIBLE_*",
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return [
|
|
752
|
+
by_provider[provider]
|
|
753
|
+
for provider in MAIN_SEARCH_FALLBACK_CHAIN
|
|
754
|
+
if provider in by_provider and _provider_allowed(provider, provider_filter)
|
|
755
|
+
]
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _main_search_providers(provider_configs: list[dict[str, Any]], fallback: str) -> list[Any]:
|
|
759
|
+
selected = provider_configs if fallback != "off" else provider_configs[:1]
|
|
760
|
+
providers: list[Any] = []
|
|
761
|
+
for provider_config in selected:
|
|
762
|
+
if provider_config["provider"] == "xai-responses":
|
|
763
|
+
providers.append(
|
|
764
|
+
XAIResponsesSearchProvider(
|
|
765
|
+
provider_config["api_url"],
|
|
766
|
+
provider_config["api_key"],
|
|
767
|
+
provider_config["model"],
|
|
768
|
+
provider_config["tools"],
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
providers.append(
|
|
773
|
+
OpenAICompatibleSearchProvider(
|
|
774
|
+
provider_config["api_url"],
|
|
775
|
+
provider_config["api_key"],
|
|
776
|
+
provider_config["model"],
|
|
777
|
+
provider_config.get("stream", False),
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
return providers
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
async def fetch_available_models(api_url: str, api_key: str) -> list[str]:
|
|
784
|
+
models_url = f"{api_url.rstrip('/')}/models"
|
|
785
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
786
|
+
response = await client.get(
|
|
787
|
+
models_url,
|
|
788
|
+
headers={
|
|
789
|
+
"Authorization": f"Bearer {api_key}",
|
|
790
|
+
"Content-Type": "application/json",
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
response.raise_for_status()
|
|
794
|
+
data = response.json()
|
|
795
|
+
|
|
796
|
+
models: list[str] = []
|
|
797
|
+
for item in (data or {}).get("data", []) or []:
|
|
798
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
799
|
+
models.append(item["id"])
|
|
800
|
+
return models
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
async def get_available_models_cached(api_url: str, api_key: str) -> list[str]:
|
|
804
|
+
key = (api_url, api_key)
|
|
805
|
+
async with _AVAILABLE_MODELS_LOCK:
|
|
806
|
+
if key in _AVAILABLE_MODELS_CACHE:
|
|
807
|
+
return _AVAILABLE_MODELS_CACHE[key]
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
models = await fetch_available_models(api_url, api_key)
|
|
811
|
+
except Exception:
|
|
812
|
+
models = []
|
|
813
|
+
|
|
814
|
+
async with _AVAILABLE_MODELS_LOCK:
|
|
815
|
+
_AVAILABLE_MODELS_CACHE[key] = models
|
|
816
|
+
return models
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def extra_results_to_sources(
|
|
820
|
+
tavily_results: list[dict] | None,
|
|
821
|
+
firecrawl_results: list[dict] | None,
|
|
822
|
+
) -> list[dict]:
|
|
823
|
+
sources: list[dict] = []
|
|
824
|
+
seen: set[str] = set()
|
|
825
|
+
|
|
826
|
+
if firecrawl_results:
|
|
827
|
+
for r in firecrawl_results:
|
|
828
|
+
url = (r.get("url") or "").strip()
|
|
829
|
+
if not url or url in seen:
|
|
830
|
+
continue
|
|
831
|
+
seen.add(url)
|
|
832
|
+
item: dict = {"url": url, "provider": "firecrawl"}
|
|
833
|
+
title = (r.get("title") or "").strip()
|
|
834
|
+
if title:
|
|
835
|
+
item["title"] = title
|
|
836
|
+
desc = (r.get("description") or "").strip()
|
|
837
|
+
if desc:
|
|
838
|
+
item["description"] = desc
|
|
839
|
+
sources.append(item)
|
|
840
|
+
|
|
841
|
+
if tavily_results:
|
|
842
|
+
for r in tavily_results:
|
|
843
|
+
url = (r.get("url") or "").strip()
|
|
844
|
+
if not url or url in seen:
|
|
845
|
+
continue
|
|
846
|
+
seen.add(url)
|
|
847
|
+
item = {"url": url, "provider": "tavily"}
|
|
848
|
+
title = (r.get("title") or "").strip()
|
|
849
|
+
if title:
|
|
850
|
+
item["title"] = title
|
|
851
|
+
content = (r.get("content") or "").strip()
|
|
852
|
+
if content:
|
|
853
|
+
item["description"] = content
|
|
854
|
+
sources.append(item)
|
|
855
|
+
|
|
856
|
+
return sources
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
async def _run_web_fetch_fallback(
|
|
860
|
+
url: str,
|
|
861
|
+
fallback: str = "auto",
|
|
862
|
+
preferred_order: list[str] | None = None,
|
|
863
|
+
) -> tuple[dict[str, Any] | None, list[dict]]:
|
|
864
|
+
attempts: list[dict] = []
|
|
865
|
+
providers = []
|
|
866
|
+
if config.tavily_api_key:
|
|
867
|
+
providers.append("tavily")
|
|
868
|
+
if config.jina_api_key:
|
|
869
|
+
providers.append("jina")
|
|
870
|
+
if config.zhipu_mcp_api_key:
|
|
871
|
+
providers.append("zhipu-mcp-reader")
|
|
872
|
+
if config.firecrawl_api_key:
|
|
873
|
+
providers.append("firecrawl")
|
|
874
|
+
if preferred_order:
|
|
875
|
+
allowed = {provider for provider in providers}
|
|
876
|
+
providers = [provider for provider in preferred_order if provider in allowed]
|
|
877
|
+
if fallback == "off":
|
|
878
|
+
providers = providers[:1]
|
|
879
|
+
|
|
880
|
+
for provider in providers:
|
|
881
|
+
start = time.time()
|
|
882
|
+
try:
|
|
883
|
+
if provider == "tavily":
|
|
884
|
+
content = await call_tavily_extract(url)
|
|
885
|
+
elif provider == "jina":
|
|
886
|
+
data = await jina_fetch(url)
|
|
887
|
+
content = data.get("content") if data.get("ok") else None
|
|
888
|
+
if not data.get("ok"):
|
|
889
|
+
status = "error" if data.get("error_type") in {"auth_error", "config_error", "parameter_error", "quality_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
|
|
890
|
+
attempts.append(_attempt("web_fetch", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
891
|
+
continue
|
|
892
|
+
elif provider == "zhipu-mcp-reader":
|
|
893
|
+
data = await zhipu_mcp_reader(url)
|
|
894
|
+
content = data.get("content") if data.get("ok") else None
|
|
895
|
+
if not data.get("ok"):
|
|
896
|
+
status = "error" if data.get("error_type") in {"auth_error", "config_error", "provider_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
|
|
897
|
+
attempts.append(_attempt("web_fetch", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
898
|
+
continue
|
|
899
|
+
else:
|
|
900
|
+
content = await call_firecrawl_scrape(url)
|
|
901
|
+
if content and content.strip():
|
|
902
|
+
attempts.append(_attempt("web_fetch", provider, "ok", start, result_count=1))
|
|
903
|
+
return {
|
|
904
|
+
"ok": True,
|
|
905
|
+
"url": url,
|
|
906
|
+
"provider": provider,
|
|
907
|
+
"content": content,
|
|
908
|
+
}, attempts
|
|
909
|
+
attempts.append(_attempt("web_fetch", provider, "empty", start))
|
|
910
|
+
except Exception as e:
|
|
911
|
+
attempts.append(_attempt("web_fetch", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
912
|
+
return None, attempts
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
async def _run_web_search_fallback(
|
|
916
|
+
query: str,
|
|
917
|
+
count: int = 5,
|
|
918
|
+
providers: str = "auto",
|
|
919
|
+
fallback: str = "auto",
|
|
920
|
+
) -> tuple[list[dict], list[dict]]:
|
|
921
|
+
provider_filter = _parse_provider_filter(providers)
|
|
922
|
+
attempts: list[dict] = []
|
|
923
|
+
configured: list[str] = []
|
|
924
|
+
if config.zhipu_api_key:
|
|
925
|
+
configured.append("zhipu")
|
|
926
|
+
if config.zhipu_mcp_api_key:
|
|
927
|
+
configured.append("zhipu-mcp")
|
|
928
|
+
if config.tavily_api_key:
|
|
929
|
+
configured.append("tavily")
|
|
930
|
+
if config.firecrawl_api_key:
|
|
931
|
+
configured.append("firecrawl")
|
|
932
|
+
if provider_filter is not None:
|
|
933
|
+
configured = [p for p in configured if p in provider_filter]
|
|
934
|
+
if fallback == "off":
|
|
935
|
+
configured = configured[:1]
|
|
936
|
+
|
|
937
|
+
for provider in configured:
|
|
938
|
+
start = time.time()
|
|
939
|
+
try:
|
|
940
|
+
if provider == "zhipu":
|
|
941
|
+
data = await zhipu_search(query, count=count)
|
|
942
|
+
if data.get("ok"):
|
|
943
|
+
sources = _normalize_source_results(data.get("results"), "zhipu")
|
|
944
|
+
if sources:
|
|
945
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
946
|
+
return sources, attempts
|
|
947
|
+
status = "error" if data.get("error_type") in {"rate_limited", "auth_error", "timeout", "network_error", "runtime_error"} else "empty"
|
|
948
|
+
attempts.append(_attempt("web_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
949
|
+
elif provider == "zhipu-mcp":
|
|
950
|
+
data = await zhipu_mcp_search(query, count=count)
|
|
951
|
+
if data.get("ok"):
|
|
952
|
+
sources = _normalize_source_results(data.get("results"), "zhipu-mcp")
|
|
953
|
+
if sources:
|
|
954
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
955
|
+
return sources, attempts
|
|
956
|
+
status = "error" if data.get("error_type") in {"rate_limited", "auth_error", "timeout", "network_error", "runtime_error", "provider_error"} else "empty"
|
|
957
|
+
attempts.append(_attempt("web_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
958
|
+
elif provider == "tavily":
|
|
959
|
+
results = await call_tavily_search(query, count)
|
|
960
|
+
sources = _normalize_source_results(results, "tavily")
|
|
961
|
+
if sources:
|
|
962
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
963
|
+
return sources, attempts
|
|
964
|
+
attempts.append(_attempt("web_search", provider, "empty", start))
|
|
965
|
+
elif provider == "firecrawl":
|
|
966
|
+
results = await call_firecrawl_search(query, count)
|
|
967
|
+
sources = _normalize_source_results(results, "firecrawl")
|
|
968
|
+
if sources:
|
|
969
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
970
|
+
return sources, attempts
|
|
971
|
+
attempts.append(_attempt("web_search", provider, "empty", start))
|
|
972
|
+
except Exception as e:
|
|
973
|
+
attempts.append(_attempt("web_search", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
974
|
+
return [], attempts
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
async def _run_docs_search_fallback(
|
|
978
|
+
query: str,
|
|
979
|
+
providers: str = "auto",
|
|
980
|
+
fallback: str = "auto",
|
|
981
|
+
) -> tuple[list[dict], list[dict]]:
|
|
982
|
+
provider_filter = _parse_provider_filter(providers)
|
|
983
|
+
attempts: list[dict] = []
|
|
984
|
+
configured: list[str] = []
|
|
985
|
+
if config.context7_api_key:
|
|
986
|
+
configured.append("context7")
|
|
987
|
+
if config.exa_api_key:
|
|
988
|
+
configured.append("exa")
|
|
989
|
+
if provider_filter is not None:
|
|
990
|
+
configured = [p for p in configured if p in provider_filter]
|
|
991
|
+
if fallback == "off":
|
|
992
|
+
configured = configured[:1]
|
|
993
|
+
|
|
994
|
+
for provider in configured:
|
|
995
|
+
start = time.time()
|
|
996
|
+
try:
|
|
997
|
+
if provider == "exa":
|
|
998
|
+
data = await exa_search(query, num_results=5, include_highlights=True)
|
|
999
|
+
if data.get("ok"):
|
|
1000
|
+
sources = _normalize_source_results(data.get("results"), "exa")
|
|
1001
|
+
if sources:
|
|
1002
|
+
attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
|
|
1003
|
+
return sources, attempts
|
|
1004
|
+
status = "error" if data.get("error_type") in {"auth_error", "parameter_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1005
|
+
attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1006
|
+
elif provider == "context7":
|
|
1007
|
+
data = await context7_library(query, query)
|
|
1008
|
+
if data.get("ok"):
|
|
1009
|
+
sources = [
|
|
1010
|
+
{
|
|
1011
|
+
"url": f"context7:{item.get('id')}",
|
|
1012
|
+
"title": item.get("title") or item.get("id") or "Context7",
|
|
1013
|
+
"description": item.get("description") or "",
|
|
1014
|
+
"provider": "context7",
|
|
1015
|
+
}
|
|
1016
|
+
for item in data.get("results", [])
|
|
1017
|
+
if item.get("id")
|
|
1018
|
+
]
|
|
1019
|
+
if sources:
|
|
1020
|
+
attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
|
|
1021
|
+
return sources, attempts
|
|
1022
|
+
status = "error" if data.get("error_type") in {"auth_error", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1023
|
+
attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
attempts.append(_attempt("docs_search", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
1026
|
+
return [], attempts
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
async def call_tavily_extract(url: str) -> str | None:
|
|
1030
|
+
api_key = config.tavily_api_key
|
|
1031
|
+
if not api_key:
|
|
1032
|
+
return None
|
|
1033
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/extract"
|
|
1034
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1035
|
+
body = {"urls": [url], "format": "markdown"}
|
|
1036
|
+
try:
|
|
1037
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
1038
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1039
|
+
response.raise_for_status()
|
|
1040
|
+
data = response.json()
|
|
1041
|
+
if data.get("results") and len(data["results"]) > 0:
|
|
1042
|
+
content = data["results"][0].get("raw_content", "")
|
|
1043
|
+
return content if content and content.strip() else None
|
|
1044
|
+
return None
|
|
1045
|
+
except Exception:
|
|
1046
|
+
return None
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
async def call_tavily_search(query: str, max_results: int = 6) -> list[dict] | None:
|
|
1050
|
+
api_key = config.tavily_api_key
|
|
1051
|
+
if not api_key:
|
|
1052
|
+
return None
|
|
1053
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/search"
|
|
1054
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1055
|
+
body = {
|
|
1056
|
+
"query": query,
|
|
1057
|
+
"max_results": max_results,
|
|
1058
|
+
"search_depth": "advanced",
|
|
1059
|
+
"include_raw_content": False,
|
|
1060
|
+
"include_answer": False,
|
|
1061
|
+
}
|
|
1062
|
+
try:
|
|
1063
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1064
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1065
|
+
response.raise_for_status()
|
|
1066
|
+
data = response.json()
|
|
1067
|
+
results = data.get("results", [])
|
|
1068
|
+
return [
|
|
1069
|
+
{
|
|
1070
|
+
"title": r.get("title", ""),
|
|
1071
|
+
"url": r.get("url", ""),
|
|
1072
|
+
"content": r.get("content", ""),
|
|
1073
|
+
"score": r.get("score", 0),
|
|
1074
|
+
}
|
|
1075
|
+
for r in results
|
|
1076
|
+
] if results else None
|
|
1077
|
+
except Exception:
|
|
1078
|
+
return None
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
async def call_firecrawl_search(query: str, limit: int = 14) -> list[dict] | None:
|
|
1082
|
+
api_key = config.firecrawl_api_key
|
|
1083
|
+
if not api_key:
|
|
1084
|
+
return None
|
|
1085
|
+
endpoint = f"{config.firecrawl_api_url.rstrip('/')}/search"
|
|
1086
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1087
|
+
body = {"query": query, "limit": limit}
|
|
1088
|
+
try:
|
|
1089
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1090
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1091
|
+
response.raise_for_status()
|
|
1092
|
+
data = response.json()
|
|
1093
|
+
results = data.get("data", {}).get("web", [])
|
|
1094
|
+
return [
|
|
1095
|
+
{
|
|
1096
|
+
"title": r.get("title", ""),
|
|
1097
|
+
"url": r.get("url", ""),
|
|
1098
|
+
"description": r.get("description", ""),
|
|
1099
|
+
}
|
|
1100
|
+
for r in results
|
|
1101
|
+
] if results else None
|
|
1102
|
+
except Exception:
|
|
1103
|
+
return None
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
async def call_firecrawl_scrape(url: str, ctx=None) -> str | None:
|
|
1107
|
+
api_key = config.firecrawl_api_key
|
|
1108
|
+
if not api_key:
|
|
1109
|
+
return None
|
|
1110
|
+
endpoint = f"{config.firecrawl_api_url.rstrip('/')}/scrape"
|
|
1111
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1112
|
+
for attempt in range(config.retry_max_attempts):
|
|
1113
|
+
body = {
|
|
1114
|
+
"url": url,
|
|
1115
|
+
"formats": ["markdown"],
|
|
1116
|
+
"timeout": 60000,
|
|
1117
|
+
"waitFor": (attempt + 1) * 1500,
|
|
1118
|
+
}
|
|
1119
|
+
try:
|
|
1120
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1121
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1122
|
+
response.raise_for_status()
|
|
1123
|
+
data = response.json()
|
|
1124
|
+
markdown = data.get("data", {}).get("markdown", "")
|
|
1125
|
+
if markdown and markdown.strip():
|
|
1126
|
+
return markdown
|
|
1127
|
+
await log_info(ctx, f"Firecrawl: markdown为空, 重试 {attempt + 1}/{config.retry_max_attempts}", config.debug_enabled)
|
|
1128
|
+
except Exception as e:
|
|
1129
|
+
await log_info(ctx, f"Firecrawl error: {e}", config.debug_enabled)
|
|
1130
|
+
return None
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
async def call_jina_reader(url: str) -> dict[str, Any]:
|
|
1135
|
+
raw = await JinaReaderProvider(
|
|
1136
|
+
config.jina_reader_api_url,
|
|
1137
|
+
config.jina_api_key,
|
|
1138
|
+
config.jina_respond_with,
|
|
1139
|
+
config.jina_timeout,
|
|
1140
|
+
).fetch(url)
|
|
1141
|
+
return await _decode_provider_json(raw, provider="jina")
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
async def call_tavily_map(
|
|
1145
|
+
url: str,
|
|
1146
|
+
instructions: str = "",
|
|
1147
|
+
max_depth: int = 1,
|
|
1148
|
+
max_breadth: int = 20,
|
|
1149
|
+
limit: int = 50,
|
|
1150
|
+
timeout: int = 150,
|
|
1151
|
+
) -> dict[str, Any]:
|
|
1152
|
+
api_key = config.tavily_api_key
|
|
1153
|
+
if not api_key:
|
|
1154
|
+
return {
|
|
1155
|
+
"ok": False,
|
|
1156
|
+
"error_type": "config_error",
|
|
1157
|
+
"error": "TAVILY_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set TAVILY_API_KEY <key>`。",
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/map"
|
|
1161
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1162
|
+
body = {"url": url, "max_depth": max_depth, "max_breadth": max_breadth, "limit": limit, "timeout": timeout}
|
|
1163
|
+
if instructions:
|
|
1164
|
+
body["instructions"] = instructions
|
|
1165
|
+
try:
|
|
1166
|
+
async with httpx.AsyncClient(timeout=float(timeout + 10)) as client:
|
|
1167
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1168
|
+
response.raise_for_status()
|
|
1169
|
+
data = response.json()
|
|
1170
|
+
return {
|
|
1171
|
+
"ok": True,
|
|
1172
|
+
"base_url": data.get("base_url", ""),
|
|
1173
|
+
"results": data.get("results", []),
|
|
1174
|
+
"response_time": data.get("response_time", 0),
|
|
1175
|
+
}
|
|
1176
|
+
except httpx.TimeoutException:
|
|
1177
|
+
return {"ok": False, "error_type": "network_error", "error": f"映射超时: 请求超过{timeout}秒"}
|
|
1178
|
+
except httpx.HTTPStatusError as e:
|
|
1179
|
+
return {"ok": False, "error_type": "network_error", "error": f"HTTP错误: {e.response.status_code} - {e.response.text[:200]}"}
|
|
1180
|
+
except Exception as e:
|
|
1181
|
+
return {"ok": False, "error_type": "network_error", "error": f"映射错误: {str(e)}"}
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
async def search(
|
|
1185
|
+
query: str,
|
|
1186
|
+
platform: str = "",
|
|
1187
|
+
model: str = "",
|
|
1188
|
+
extra_sources: int = 0,
|
|
1189
|
+
validation: str = "",
|
|
1190
|
+
fallback: str = "",
|
|
1191
|
+
providers: str = "auto",
|
|
1192
|
+
stream: bool | None = None,
|
|
1193
|
+
timeout_seconds: float | None = None,
|
|
1194
|
+
enforce_minimum_profile: bool = True,
|
|
1195
|
+
) -> dict[str, Any]:
|
|
1196
|
+
start = time.time()
|
|
1197
|
+
session_id = new_session_id()
|
|
1198
|
+
try:
|
|
1199
|
+
validation_level = (validation or config.validation_level).strip().lower()
|
|
1200
|
+
fallback_mode = (fallback or config.fallback_mode).strip().lower()
|
|
1201
|
+
if validation_level not in config._ALLOWED_VALIDATION_LEVELS:
|
|
1202
|
+
raise ValueError(f"Invalid validation level: {validation_level}")
|
|
1203
|
+
if fallback_mode not in config._ALLOWED_FALLBACK_MODES:
|
|
1204
|
+
raise ValueError(f"Invalid fallback mode: {fallback_mode}")
|
|
1205
|
+
except ValueError as e:
|
|
1206
|
+
return _empty_search_result(start, session_id, query, "parameter_error", str(e))
|
|
1207
|
+
|
|
1208
|
+
minimum = validate_minimum_profile()
|
|
1209
|
+
if enforce_minimum_profile and not minimum.get("ok"):
|
|
1210
|
+
return _empty_search_result(
|
|
1211
|
+
start,
|
|
1212
|
+
session_id,
|
|
1213
|
+
query,
|
|
1214
|
+
minimum.get("error_type", "config_error"),
|
|
1215
|
+
minimum.get("error", MINIMUM_PROFILE_ERROR),
|
|
1216
|
+
extra={
|
|
1217
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1218
|
+
"minimum_profile_ok": False,
|
|
1219
|
+
"validation_level": validation_level,
|
|
1220
|
+
},
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
try:
|
|
1224
|
+
main_provider_configs = _main_search_provider_configs(model_override=model, providers=providers)
|
|
1225
|
+
except ValueError as e:
|
|
1226
|
+
return _empty_search_result(start, session_id, query, "parameter_error", str(e), extra={"validation_level": validation_level})
|
|
1227
|
+
|
|
1228
|
+
if not main_provider_configs:
|
|
1229
|
+
return _empty_search_result(
|
|
1230
|
+
start,
|
|
1231
|
+
session_id,
|
|
1232
|
+
query,
|
|
1233
|
+
"config_error",
|
|
1234
|
+
"No configured main_search provider matches --providers.",
|
|
1235
|
+
extra={
|
|
1236
|
+
"validation_level": validation_level,
|
|
1237
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1238
|
+
"minimum_profile_ok": minimum.get("ok", False),
|
|
1239
|
+
},
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
primary_api_mode = main_provider_configs[0]["mode"]
|
|
1243
|
+
if stream is not None:
|
|
1244
|
+
for provider_config in main_provider_configs:
|
|
1245
|
+
if provider_config["provider"] == "openai-compatible":
|
|
1246
|
+
provider_config["stream"] = stream
|
|
1247
|
+
|
|
1248
|
+
has_tavily = bool(config.tavily_api_key)
|
|
1249
|
+
has_firecrawl = bool(config.firecrawl_api_key)
|
|
1250
|
+
tavily_count = 0
|
|
1251
|
+
firecrawl_count = 0
|
|
1252
|
+
if extra_sources > 0:
|
|
1253
|
+
if has_tavily and has_firecrawl:
|
|
1254
|
+
tavily_count = max(1, round(extra_sources * 0.6))
|
|
1255
|
+
firecrawl_count = extra_sources - tavily_count
|
|
1256
|
+
elif has_tavily:
|
|
1257
|
+
tavily_count = extra_sources
|
|
1258
|
+
elif has_firecrawl:
|
|
1259
|
+
firecrawl_count = extra_sources
|
|
1260
|
+
|
|
1261
|
+
selected_main_provider_configs = main_provider_configs if fallback_mode != "off" else main_provider_configs[:1]
|
|
1262
|
+
try:
|
|
1263
|
+
route_result = await IntentRouter(config).route(query, validation_level=validation_level, allow_remote=True)
|
|
1264
|
+
except ValueError as e:
|
|
1265
|
+
return _empty_search_result(start, session_id, query, "parameter_error", str(e), extra={"validation_level": validation_level})
|
|
1266
|
+
fetch_urls = _extract_urls(query)
|
|
1267
|
+
supplemental_paths = route_result.required_capabilities
|
|
1268
|
+
openai_candidate_models = next(
|
|
1269
|
+
(
|
|
1270
|
+
[candidate["model"] for candidate in _openai_model_candidates(item, fallback_mode=fallback_mode, model_override=model)]
|
|
1271
|
+
for item in selected_main_provider_configs
|
|
1272
|
+
if item["provider"] == "openai-compatible"
|
|
1273
|
+
),
|
|
1274
|
+
[],
|
|
1275
|
+
)
|
|
1276
|
+
routing_decision = {
|
|
1277
|
+
**route_result.to_dict(),
|
|
1278
|
+
"validation_level": validation_level,
|
|
1279
|
+
"fallback_mode": fallback_mode,
|
|
1280
|
+
"providers": providers,
|
|
1281
|
+
"main_search_chain": [item["provider"] for item in selected_main_provider_configs],
|
|
1282
|
+
"openai_compatible_stream": next((bool(item.get("stream")) for item in selected_main_provider_configs if item["provider"] == "openai-compatible"), False),
|
|
1283
|
+
"openai_compatible_models": openai_candidate_models,
|
|
1284
|
+
"openai_compatible_model_fallback_enabled": len(openai_candidate_models) > 1,
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
provider_attempts: list[dict] = []
|
|
1288
|
+
primary_start = time.time()
|
|
1289
|
+
primary_result = None
|
|
1290
|
+
successful_main_config: dict[str, Any] | None = None
|
|
1291
|
+
last_primary_error: dict[str, Any] | None = None
|
|
1292
|
+
model_fallback_used = False
|
|
1293
|
+
transport_fallback_used = False
|
|
1294
|
+
total_main_candidates = sum(
|
|
1295
|
+
len(_openai_model_candidates(item, fallback_mode=fallback_mode, model_override=model))
|
|
1296
|
+
if item["provider"] == "openai-compatible"
|
|
1297
|
+
else 1
|
|
1298
|
+
for item in selected_main_provider_configs
|
|
1299
|
+
)
|
|
1300
|
+
completed_main_candidates = 0
|
|
1301
|
+
for provider_config in selected_main_provider_configs:
|
|
1302
|
+
provider_candidates = (
|
|
1303
|
+
_openai_model_candidates(provider_config, fallback_mode=fallback_mode, model_override=model)
|
|
1304
|
+
if provider_config["provider"] == "openai-compatible"
|
|
1305
|
+
else [provider_config]
|
|
1306
|
+
)
|
|
1307
|
+
for candidate_config in provider_candidates:
|
|
1308
|
+
completed_main_candidates += 1
|
|
1309
|
+
primary_start = time.time()
|
|
1310
|
+
search_provider = _main_search_providers([candidate_config], fallback="auto")[0]
|
|
1311
|
+
attempt_extra: dict[str, Any] = {}
|
|
1312
|
+
if candidate_config["provider"] == "openai-compatible":
|
|
1313
|
+
attempt_extra["model"] = candidate_config["model"]
|
|
1314
|
+
attempt_extra["model_role"] = candidate_config.get("model_role", "primary")
|
|
1315
|
+
if candidate_config.get("fallback_from_model"):
|
|
1316
|
+
attempt_extra["fallback_from_model"] = candidate_config["fallback_from_model"]
|
|
1317
|
+
model_fallback_used = True
|
|
1318
|
+
breaker_state = _openai_model_breaker_state(candidate_config["api_url"], candidate_config["model"])
|
|
1319
|
+
if breaker_state.get("state") == "open":
|
|
1320
|
+
attempt_extra["breaker_state"] = breaker_state
|
|
1321
|
+
provider_attempts.append(
|
|
1322
|
+
_attempt(
|
|
1323
|
+
"main_search",
|
|
1324
|
+
"OpenAI-compatible",
|
|
1325
|
+
"skipped",
|
|
1326
|
+
primary_start,
|
|
1327
|
+
error_type="network_error",
|
|
1328
|
+
error="model breaker open",
|
|
1329
|
+
extra=attempt_extra,
|
|
1330
|
+
)
|
|
1331
|
+
)
|
|
1332
|
+
continue
|
|
1333
|
+
attempt_timeout = _attempt_timeout_seconds(
|
|
1334
|
+
start,
|
|
1335
|
+
timeout_seconds,
|
|
1336
|
+
total_main_candidates - completed_main_candidates + 1,
|
|
1337
|
+
)
|
|
1338
|
+
try:
|
|
1339
|
+
if attempt_timeout is not None:
|
|
1340
|
+
candidate_result = await asyncio.wait_for(search_provider.search(query, platform), timeout=attempt_timeout)
|
|
1341
|
+
else:
|
|
1342
|
+
candidate_result = await search_provider.search(query, platform)
|
|
1343
|
+
transport_attempts = getattr(search_provider, "last_transport_attempts", [])
|
|
1344
|
+
if _append_openai_transport_attempts(provider_attempts, search_provider, candidate_config):
|
|
1345
|
+
transport_fallback_used = transport_fallback_used or any(
|
|
1346
|
+
attempt.get("fallback_from_transport") for attempt in transport_attempts
|
|
1347
|
+
)
|
|
1348
|
+
if candidate_result:
|
|
1349
|
+
primary_result = candidate_result
|
|
1350
|
+
successful_main_config = candidate_config
|
|
1351
|
+
if candidate_config["provider"] != "openai-compatible" or not transport_attempts:
|
|
1352
|
+
provider_attempts.append(
|
|
1353
|
+
_attempt(
|
|
1354
|
+
"main_search",
|
|
1355
|
+
search_provider.get_provider_name(),
|
|
1356
|
+
"ok",
|
|
1357
|
+
primary_start,
|
|
1358
|
+
result_count=1,
|
|
1359
|
+
extra=attempt_extra,
|
|
1360
|
+
)
|
|
1361
|
+
)
|
|
1362
|
+
if candidate_config["provider"] == "openai-compatible":
|
|
1363
|
+
_record_openai_model_success(candidate_config["api_url"], candidate_config["model"])
|
|
1364
|
+
break
|
|
1365
|
+
if candidate_config["provider"] == "openai-compatible":
|
|
1366
|
+
attempt_extra["breaker_state"] = _record_openai_model_failure(candidate_config["api_url"], candidate_config["model"])
|
|
1367
|
+
last_primary_error = _primary_search_error_result(
|
|
1368
|
+
start,
|
|
1369
|
+
session_id,
|
|
1370
|
+
query,
|
|
1371
|
+
candidate_config["mode"],
|
|
1372
|
+
"network_error",
|
|
1373
|
+
f"{search_provider.get_provider_name()} 返回空结果",
|
|
1374
|
+
)
|
|
1375
|
+
if candidate_config["provider"] != "openai-compatible" or not transport_attempts:
|
|
1376
|
+
provider_attempts.append(
|
|
1377
|
+
_attempt("main_search", search_provider.get_provider_name(), "empty", primary_start, extra=attempt_extra)
|
|
1378
|
+
)
|
|
1379
|
+
except Exception as e:
|
|
1380
|
+
error_result = _primary_search_exception_result(start, session_id, query, candidate_config["mode"], search_provider.get_provider_name(), e)
|
|
1381
|
+
last_primary_error = error_result
|
|
1382
|
+
transport_attempts = getattr(search_provider, "last_transport_attempts", [])
|
|
1383
|
+
if _append_openai_transport_attempts(provider_attempts, search_provider, candidate_config):
|
|
1384
|
+
transport_fallback_used = transport_fallback_used or any(
|
|
1385
|
+
attempt.get("fallback_from_transport") for attempt in transport_attempts
|
|
1386
|
+
)
|
|
1387
|
+
if candidate_config["provider"] == "openai-compatible":
|
|
1388
|
+
attempt_extra["breaker_state"] = _record_openai_model_failure(candidate_config["api_url"], candidate_config["model"])
|
|
1389
|
+
if candidate_config["provider"] != "openai-compatible" or not transport_attempts:
|
|
1390
|
+
provider_attempts.append(
|
|
1391
|
+
_attempt(
|
|
1392
|
+
"main_search",
|
|
1393
|
+
search_provider.get_provider_name(),
|
|
1394
|
+
"error",
|
|
1395
|
+
primary_start,
|
|
1396
|
+
error_type=error_result["error_type"],
|
|
1397
|
+
error=error_result["error"],
|
|
1398
|
+
extra=attempt_extra,
|
|
1399
|
+
)
|
|
1400
|
+
)
|
|
1401
|
+
if primary_result is not None:
|
|
1402
|
+
break
|
|
1403
|
+
if primary_result is None:
|
|
1404
|
+
result = last_primary_error or _primary_search_error_result(start, session_id, query, primary_api_mode, "network_error", "搜索失败或无结果")
|
|
1405
|
+
result["provider_attempts"] = provider_attempts
|
|
1406
|
+
result["providers_used"] = _provider_names_from_attempts(provider_attempts)
|
|
1407
|
+
result["fallback_used"] = _fallback_used(provider_attempts)
|
|
1408
|
+
result["transport_fallback_used"] = transport_fallback_used
|
|
1409
|
+
result["model_fallback_used"] = model_fallback_used
|
|
1410
|
+
result["routing_decision"] = routing_decision
|
|
1411
|
+
result["validation_level"] = validation_level
|
|
1412
|
+
result["minimum_profile_ok"] = minimum.get("ok", False)
|
|
1413
|
+
result["capability_status"] = minimum.get("capability_status", {})
|
|
1414
|
+
return result
|
|
1415
|
+
|
|
1416
|
+
successful_main_config = successful_main_config or selected_main_provider_configs[0]
|
|
1417
|
+
primary_api_mode = successful_main_config["mode"]
|
|
1418
|
+
effective_model = successful_main_config["model"]
|
|
1419
|
+
|
|
1420
|
+
coros: list[Any] = []
|
|
1421
|
+
if tavily_count:
|
|
1422
|
+
coros.append(call_tavily_search(query, tavily_count))
|
|
1423
|
+
if firecrawl_count:
|
|
1424
|
+
coros.append(call_firecrawl_search(query, firecrawl_count))
|
|
1425
|
+
|
|
1426
|
+
gathered = await asyncio.gather(*coros, return_exceptions=True)
|
|
1427
|
+
primary_result = primary_result or ""
|
|
1428
|
+
tavily_results: list[dict] | None = None
|
|
1429
|
+
firecrawl_results: list[dict] | None = None
|
|
1430
|
+
idx = 0
|
|
1431
|
+
if tavily_count:
|
|
1432
|
+
tavily_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
|
|
1433
|
+
idx += 1
|
|
1434
|
+
if firecrawl_count:
|
|
1435
|
+
firecrawl_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
|
|
1436
|
+
|
|
1437
|
+
answer, primary_sources = split_answer_and_sources(primary_result)
|
|
1438
|
+
extra_source_items = extra_results_to_sources(tavily_results, firecrawl_results)
|
|
1439
|
+
for item_provider, results in (("tavily", tavily_results), ("firecrawl", firecrawl_results)):
|
|
1440
|
+
if results:
|
|
1441
|
+
provider_attempts.append(_attempt("web_search", item_provider, "ok", start, result_count=len(results)))
|
|
1442
|
+
|
|
1443
|
+
supplemental_sources: list[dict] = []
|
|
1444
|
+
if validation_level in {"balanced", "strict"}:
|
|
1445
|
+
if "docs_search" in supplemental_paths:
|
|
1446
|
+
docs_sources, docs_attempts = await _run_docs_search_fallback(query, providers=providers, fallback=fallback_mode)
|
|
1447
|
+
provider_attempts.extend(docs_attempts)
|
|
1448
|
+
supplemental_sources.extend(docs_sources)
|
|
1449
|
+
if "web_search" in supplemental_paths:
|
|
1450
|
+
web_sources, web_attempts = await _run_web_search_fallback(query, count=max(1, extra_sources or 3), providers=providers, fallback=fallback_mode)
|
|
1451
|
+
provider_attempts.extend(web_attempts)
|
|
1452
|
+
supplemental_sources.extend(web_sources)
|
|
1453
|
+
if "web_fetch" in supplemental_paths:
|
|
1454
|
+
fetch_url = fetch_urls[0] if fetch_urls else query.strip()
|
|
1455
|
+
fetch_result, fetch_attempts = await _run_web_fetch_fallback(fetch_url, fallback=fallback_mode)
|
|
1456
|
+
provider_attempts.extend(fetch_attempts)
|
|
1457
|
+
if fetch_result:
|
|
1458
|
+
supplemental_sources.append({"url": fetch_result["url"], "provider": fetch_result["provider"], "description": fetch_result["content"][:300]})
|
|
1459
|
+
|
|
1460
|
+
extra_source_items = merge_sources(extra_source_items, supplemental_sources)
|
|
1461
|
+
sources = merge_sources(primary_sources, extra_source_items)
|
|
1462
|
+
ok = bool(answer or sources)
|
|
1463
|
+
if validation_level == "strict" and not sources:
|
|
1464
|
+
ok = False
|
|
1465
|
+
return {
|
|
1466
|
+
"ok": ok,
|
|
1467
|
+
"error_type": "" if ok else ("evidence_error" if validation_level == "strict" else "network_error"),
|
|
1468
|
+
"error": "" if ok else ("strict 模式证据不足" if validation_level == "strict" else "搜索失败或无结果"),
|
|
1469
|
+
"session_id": session_id,
|
|
1470
|
+
"query": query,
|
|
1471
|
+
"platform": platform,
|
|
1472
|
+
"model": effective_model,
|
|
1473
|
+
"primary_api_mode": primary_api_mode,
|
|
1474
|
+
"content": answer,
|
|
1475
|
+
"sources": sources,
|
|
1476
|
+
"sources_count": len(sources),
|
|
1477
|
+
"primary_sources": primary_sources,
|
|
1478
|
+
"primary_sources_count": len(primary_sources),
|
|
1479
|
+
"extra_sources": extra_source_items,
|
|
1480
|
+
"extra_sources_count": len(extra_source_items),
|
|
1481
|
+
"source_warning": SOURCE_PROVENANCE_WARNING if extra_source_items else "",
|
|
1482
|
+
"routing_decision": routing_decision,
|
|
1483
|
+
"providers_used": _provider_names_from_attempts(provider_attempts),
|
|
1484
|
+
"provider_attempts": provider_attempts,
|
|
1485
|
+
"fallback_used": _fallback_used(provider_attempts),
|
|
1486
|
+
"transport_fallback_used": transport_fallback_used,
|
|
1487
|
+
"model_fallback_used": model_fallback_used,
|
|
1488
|
+
"validation_level": validation_level,
|
|
1489
|
+
"minimum_profile_ok": minimum.get("ok", False),
|
|
1490
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1491
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
async def route(
|
|
1496
|
+
query: str,
|
|
1497
|
+
validation: str = "",
|
|
1498
|
+
mode: str = "",
|
|
1499
|
+
allow_remote: bool = True,
|
|
1500
|
+
) -> dict[str, Any]:
|
|
1501
|
+
start = time.time()
|
|
1502
|
+
try:
|
|
1503
|
+
validation_level = (validation or config.validation_level).strip().lower()
|
|
1504
|
+
if validation_level not in config._ALLOWED_VALIDATION_LEVELS:
|
|
1505
|
+
raise ValueError(f"Invalid validation level: {validation_level}")
|
|
1506
|
+
route_result = await IntentRouter(config).route(
|
|
1507
|
+
query,
|
|
1508
|
+
validation_level=validation_level,
|
|
1509
|
+
mode=mode,
|
|
1510
|
+
allow_remote=allow_remote,
|
|
1511
|
+
)
|
|
1512
|
+
except ValueError as e:
|
|
1513
|
+
return {
|
|
1514
|
+
"ok": False,
|
|
1515
|
+
"query": query,
|
|
1516
|
+
"error_type": "parameter_error",
|
|
1517
|
+
"error": str(e),
|
|
1518
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1519
|
+
}
|
|
1520
|
+
data = route_result.to_dict()
|
|
1521
|
+
router_status = intent_router_status()
|
|
1522
|
+
preset_fields = {
|
|
1523
|
+
key: router_status.get(key)
|
|
1524
|
+
for key in (
|
|
1525
|
+
"embedding_preset_id",
|
|
1526
|
+
"embedding_preset_model",
|
|
1527
|
+
"embedding_preset_api_url",
|
|
1528
|
+
"embedding_preset_threshold",
|
|
1529
|
+
"embedding_preset_margin",
|
|
1530
|
+
"embedding_preset_threshold_matches",
|
|
1531
|
+
"embedding_preset_margin_matches",
|
|
1532
|
+
"embedding_preset_recommended",
|
|
1533
|
+
"embedding_preset_recommendation",
|
|
1534
|
+
"embedding_preset_commands",
|
|
1535
|
+
)
|
|
1536
|
+
if key in router_status
|
|
1537
|
+
}
|
|
1538
|
+
data.update(
|
|
1539
|
+
{
|
|
1540
|
+
"ok": True,
|
|
1541
|
+
"query": query,
|
|
1542
|
+
"validation_level": validation_level,
|
|
1543
|
+
"executed_search": False,
|
|
1544
|
+
"provider_selection": "not_executed",
|
|
1545
|
+
"embedding_model": router_status.get("embedding_model", ""),
|
|
1546
|
+
"embedding_threshold": router_status.get("embedding_threshold", ""),
|
|
1547
|
+
"embedding_margin": router_status.get("embedding_margin", ""),
|
|
1548
|
+
"embedding_threshold_source": router_status.get("embedding_threshold_source", ""),
|
|
1549
|
+
"embedding_margin_source": router_status.get("embedding_margin_source", ""),
|
|
1550
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1551
|
+
**preset_fields,
|
|
1552
|
+
}
|
|
1553
|
+
)
|
|
1554
|
+
return data
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
class _CalibrationConfigProxy:
|
|
1558
|
+
def __init__(self, base_config: Any, model: str, threshold: float, margin: float):
|
|
1559
|
+
self._base_config = base_config
|
|
1560
|
+
self._model = model
|
|
1561
|
+
self._threshold = threshold
|
|
1562
|
+
self._margin = margin
|
|
1563
|
+
|
|
1564
|
+
@property
|
|
1565
|
+
def intent_router_mode(self) -> str:
|
|
1566
|
+
return "hybrid"
|
|
1567
|
+
|
|
1568
|
+
@property
|
|
1569
|
+
def intent_embedding_model(self) -> str:
|
|
1570
|
+
return self._model
|
|
1571
|
+
|
|
1572
|
+
@property
|
|
1573
|
+
def intent_embedding_threshold(self) -> float:
|
|
1574
|
+
return self._threshold
|
|
1575
|
+
|
|
1576
|
+
@property
|
|
1577
|
+
def intent_embedding_margin(self) -> float:
|
|
1578
|
+
return self._margin
|
|
1579
|
+
|
|
1580
|
+
def get_config_source(self, key: str) -> str:
|
|
1581
|
+
if key in {"INTENT_EMBEDDING_MODEL", "INTENT_EMBEDDING_THRESHOLD", "INTENT_EMBEDDING_MARGIN"}:
|
|
1582
|
+
return "calibration"
|
|
1583
|
+
getter = getattr(self._base_config, "get_config_source", None)
|
|
1584
|
+
if callable(getter):
|
|
1585
|
+
return str(getter(key))
|
|
1586
|
+
return "default"
|
|
1587
|
+
|
|
1588
|
+
def __getattr__(self, name: str) -> Any:
|
|
1589
|
+
return getattr(self._base_config, name)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _dedupe_preserve_order(values: list[str]) -> list[str]:
|
|
1593
|
+
seen: set[str] = set()
|
|
1594
|
+
out: list[str] = []
|
|
1595
|
+
for value in values:
|
|
1596
|
+
item = value.strip()
|
|
1597
|
+
if item and item not in seen:
|
|
1598
|
+
seen.add(item)
|
|
1599
|
+
out.append(item)
|
|
1600
|
+
return out
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _parse_calibration_models(models: str = "") -> list[str]:
|
|
1604
|
+
if models.strip():
|
|
1605
|
+
return _dedupe_preserve_order([item.strip() for item in models.split(",")])
|
|
1606
|
+
defaults = list(DEFAULT_ROUTE_CALIBRATION_MODELS)
|
|
1607
|
+
current = config.intent_embedding_model
|
|
1608
|
+
if current:
|
|
1609
|
+
defaults.append(current)
|
|
1610
|
+
return _dedupe_preserve_order(defaults)
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _configured_embedding_threshold() -> float:
|
|
1614
|
+
try:
|
|
1615
|
+
return config.intent_embedding_threshold
|
|
1616
|
+
except ValueError:
|
|
1617
|
+
return DEFAULT_SEMANTIC_CONFIDENCE_THRESHOLD
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _configured_embedding_margin() -> float:
|
|
1621
|
+
try:
|
|
1622
|
+
return config.intent_embedding_margin
|
|
1623
|
+
except ValueError:
|
|
1624
|
+
return DEFAULT_SEMANTIC_CONFIDENCE_MARGIN
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _route_calibration_dataset() -> list[dict[str, Any]]:
|
|
1628
|
+
examples: list[dict[str, Any]] = []
|
|
1629
|
+
for label, queries in ROUTE_CALIBRATION_QUERIES.items():
|
|
1630
|
+
expected = [] if label == "none" else [label]
|
|
1631
|
+
for index, query_text in enumerate(queries, 1):
|
|
1632
|
+
examples.append(
|
|
1633
|
+
{
|
|
1634
|
+
"id": f"{label}-{index:02d}",
|
|
1635
|
+
"query": query_text,
|
|
1636
|
+
"expected_capabilities": list(expected),
|
|
1637
|
+
"expected_label": label,
|
|
1638
|
+
}
|
|
1639
|
+
)
|
|
1640
|
+
return examples
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
async def _embed_in_batches(router: IntentRouter, inputs: list[str], batch_size: int = 64) -> list[list[float]]:
|
|
1644
|
+
embeddings: list[list[float]] = []
|
|
1645
|
+
for start_index in range(0, len(inputs), batch_size):
|
|
1646
|
+
embeddings.extend(await router._embed(inputs[start_index : start_index + batch_size]))
|
|
1647
|
+
return embeddings
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _label_present(capabilities: set[str], label: str) -> bool:
|
|
1651
|
+
if label == "none":
|
|
1652
|
+
return not capabilities
|
|
1653
|
+
return label in capabilities
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _macro_f1(expected: list[set[str]], predicted: list[set[str]], labels: list[str]) -> dict[str, Any]:
|
|
1657
|
+
per_label: dict[str, float] = {}
|
|
1658
|
+
for label in labels:
|
|
1659
|
+
true_positive = 0
|
|
1660
|
+
false_positive = 0
|
|
1661
|
+
false_negative = 0
|
|
1662
|
+
for expected_caps, predicted_caps in zip(expected, predicted):
|
|
1663
|
+
expected_has = _label_present(expected_caps, label)
|
|
1664
|
+
predicted_has = _label_present(predicted_caps, label)
|
|
1665
|
+
if expected_has and predicted_has:
|
|
1666
|
+
true_positive += 1
|
|
1667
|
+
elif not expected_has and predicted_has:
|
|
1668
|
+
false_positive += 1
|
|
1669
|
+
elif expected_has and not predicted_has:
|
|
1670
|
+
false_negative += 1
|
|
1671
|
+
precision = true_positive / (true_positive + false_positive) if true_positive + false_positive else 0.0
|
|
1672
|
+
recall = true_positive / (true_positive + false_negative) if true_positive + false_negative else 0.0
|
|
1673
|
+
per_label[label] = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
|
1674
|
+
macro = sum(per_label.values()) / len(labels) if labels else 0.0
|
|
1675
|
+
return {
|
|
1676
|
+
"macro_f1": round(macro, 4),
|
|
1677
|
+
"per_label_f1": {label: round(score, 4) for label, score in per_label.items()},
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def _confusion_label(capabilities: set[str]) -> str:
|
|
1682
|
+
ordered = _ordered_capabilities(capabilities)
|
|
1683
|
+
if not ordered:
|
|
1684
|
+
return "none"
|
|
1685
|
+
if len(ordered) == 1:
|
|
1686
|
+
return ordered[0]
|
|
1687
|
+
return "+".join(ordered)
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _confusion_matrix(expected: list[set[str]], predicted: list[set[str]]) -> dict[str, dict[str, int]]:
|
|
1691
|
+
matrix: dict[str, dict[str, int]] = {}
|
|
1692
|
+
for expected_caps, predicted_caps in zip(expected, predicted):
|
|
1693
|
+
actual = _confusion_label(expected_caps)
|
|
1694
|
+
guessed = _confusion_label(predicted_caps)
|
|
1695
|
+
matrix.setdefault(actual, {})
|
|
1696
|
+
matrix[actual][guessed] = matrix[actual].get(guessed, 0) + 1
|
|
1697
|
+
return matrix
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _semantic_predictions(
|
|
1701
|
+
records: list[dict[str, Any]],
|
|
1702
|
+
threshold: float,
|
|
1703
|
+
margin: float,
|
|
1704
|
+
) -> tuple[list[set[str]], list[dict[str, Any]]]:
|
|
1705
|
+
predictions: list[set[str]] = []
|
|
1706
|
+
summaries: list[dict[str, Any]] = []
|
|
1707
|
+
for record in records:
|
|
1708
|
+
summary = _semantic_summary(record["scores"], threshold, margin)
|
|
1709
|
+
summaries.append(summary)
|
|
1710
|
+
if summary["passed_threshold"] and summary["passed_margin"]:
|
|
1711
|
+
predictions.append({str(summary["top_capability"])})
|
|
1712
|
+
else:
|
|
1713
|
+
predictions.append(set())
|
|
1714
|
+
return predictions, summaries
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _candidate_thresholds(records: list[dict[str, Any]]) -> list[float]:
|
|
1718
|
+
values = {round(index / 100, 2) for index in range(50, 96)}
|
|
1719
|
+
values.add(round(_configured_embedding_threshold(), 2))
|
|
1720
|
+
for record in records:
|
|
1721
|
+
summary = _semantic_summary(record["scores"], 0.0, 0.0)
|
|
1722
|
+
top_score = float(summary["top_score"])
|
|
1723
|
+
for delta in (-0.02, -0.01, 0.0, 0.01, 0.02):
|
|
1724
|
+
value = max(0.0, min(1.0, top_score + delta))
|
|
1725
|
+
values.add(round(value, 3))
|
|
1726
|
+
return sorted(values)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _candidate_margins(records: list[dict[str, Any]]) -> list[float]:
|
|
1730
|
+
values = {round(index / 100, 2) for index in range(0, 21)}
|
|
1731
|
+
values.add(round(_configured_embedding_margin(), 2))
|
|
1732
|
+
for record in records:
|
|
1733
|
+
summary = _semantic_summary(record["scores"], 0.0, 0.0)
|
|
1734
|
+
score_margin = float(summary["margin"])
|
|
1735
|
+
for delta in (-0.02, -0.01, 0.0, 0.01, 0.02):
|
|
1736
|
+
value = max(0.0, min(1.0, score_margin + delta))
|
|
1737
|
+
values.add(round(value, 3))
|
|
1738
|
+
return sorted(values)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _select_semantic_parameters(
|
|
1742
|
+
records: list[dict[str, Any]],
|
|
1743
|
+
expected: list[set[str]],
|
|
1744
|
+
labels: list[str],
|
|
1745
|
+
) -> dict[str, Any]:
|
|
1746
|
+
best: dict[str, Any] | None = None
|
|
1747
|
+
thresholds = _candidate_thresholds(records)
|
|
1748
|
+
margins = _candidate_margins(records)
|
|
1749
|
+
for threshold in thresholds:
|
|
1750
|
+
for margin in margins:
|
|
1751
|
+
predictions, _ = _semantic_predictions(records, threshold, margin)
|
|
1752
|
+
metrics = _macro_f1(expected, predictions, labels)
|
|
1753
|
+
failures = sum(1 for left, right in zip(expected, predictions) if left != right)
|
|
1754
|
+
candidate = {
|
|
1755
|
+
"threshold": threshold,
|
|
1756
|
+
"margin": margin,
|
|
1757
|
+
"macro_f1": metrics["macro_f1"],
|
|
1758
|
+
"per_label_f1": metrics["per_label_f1"],
|
|
1759
|
+
"failures": failures,
|
|
1760
|
+
}
|
|
1761
|
+
if best is None:
|
|
1762
|
+
best = candidate
|
|
1763
|
+
continue
|
|
1764
|
+
current_key = (candidate["macro_f1"], -candidate["failures"], candidate["threshold"], candidate["margin"])
|
|
1765
|
+
best_key = (best["macro_f1"], -best["failures"], best["threshold"], best["margin"])
|
|
1766
|
+
if current_key > best_key:
|
|
1767
|
+
best = candidate
|
|
1768
|
+
return best or {
|
|
1769
|
+
"threshold": _configured_embedding_threshold(),
|
|
1770
|
+
"margin": _configured_embedding_margin(),
|
|
1771
|
+
"macro_f1": 0.0,
|
|
1772
|
+
"per_label_f1": {},
|
|
1773
|
+
"failures": len(records),
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _representative_failures(
|
|
1778
|
+
records: list[dict[str, Any]],
|
|
1779
|
+
expected: list[set[str]],
|
|
1780
|
+
predicted: list[set[str]],
|
|
1781
|
+
summaries: list[dict[str, Any]],
|
|
1782
|
+
limit: int = 12,
|
|
1783
|
+
) -> list[dict[str, Any]]:
|
|
1784
|
+
failures: list[dict[str, Any]] = []
|
|
1785
|
+
for record, expected_caps, predicted_caps, summary in zip(records, expected, predicted, summaries):
|
|
1786
|
+
if expected_caps == predicted_caps:
|
|
1787
|
+
continue
|
|
1788
|
+
rounded_scores = {
|
|
1789
|
+
capability: round(float(score), 4)
|
|
1790
|
+
for capability, score in sorted(record["scores"].items(), key=lambda item: item[0])
|
|
1791
|
+
}
|
|
1792
|
+
failures.append(
|
|
1793
|
+
{
|
|
1794
|
+
"id": record["case"]["id"],
|
|
1795
|
+
"query": record["case"]["query"],
|
|
1796
|
+
"expected": _confusion_label(expected_caps),
|
|
1797
|
+
"predicted": _confusion_label(predicted_caps),
|
|
1798
|
+
"top_capability": summary["top_capability"],
|
|
1799
|
+
"top_score": round(float(summary["top_score"]), 4),
|
|
1800
|
+
"second_score": round(float(summary["second_score"]), 4),
|
|
1801
|
+
"margin": round(float(summary["margin"]), 4),
|
|
1802
|
+
"scores": rounded_scores,
|
|
1803
|
+
}
|
|
1804
|
+
)
|
|
1805
|
+
if len(failures) >= limit:
|
|
1806
|
+
break
|
|
1807
|
+
return failures
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
async def _full_route_predictions(
|
|
1811
|
+
records: list[dict[str, Any]],
|
|
1812
|
+
threshold: float,
|
|
1813
|
+
margin: float,
|
|
1814
|
+
model: str,
|
|
1815
|
+
) -> tuple[list[set[str]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
1816
|
+
proxy = _CalibrationConfigProxy(config, model, threshold, margin)
|
|
1817
|
+
router = IntentRouter(proxy)
|
|
1818
|
+
predictions: list[set[str]] = []
|
|
1819
|
+
summaries: list[dict[str, Any]] = []
|
|
1820
|
+
component_failures: list[dict[str, Any]] = []
|
|
1821
|
+
for record in records:
|
|
1822
|
+
query_text = record["case"]["query"]
|
|
1823
|
+
rules = build_rules_route(query_text, validation_level="balanced", mode="hybrid")
|
|
1824
|
+
merged_caps = set(rules.required_capabilities)
|
|
1825
|
+
summary = _semantic_summary(record["scores"], threshold, margin)
|
|
1826
|
+
summaries.append(summary)
|
|
1827
|
+
semantic = {"scores": record["scores"], **summary}
|
|
1828
|
+
if summary["passed_threshold"] and summary["passed_margin"]:
|
|
1829
|
+
merged_caps.add(str(summary["top_capability"]))
|
|
1830
|
+
if router._classifier_configured():
|
|
1831
|
+
try:
|
|
1832
|
+
classifier = await router._classifier_route(query_text, rules.to_dict(), semantic)
|
|
1833
|
+
for capability in classifier.get("required_capabilities") or []:
|
|
1834
|
+
if capability in ROUTABLE_CAPABILITIES and _classifier_can_add_capability(capability, rules):
|
|
1835
|
+
merged_caps.add(str(capability))
|
|
1836
|
+
except Exception as exc:
|
|
1837
|
+
if len(component_failures) < 10:
|
|
1838
|
+
component_failures.append(
|
|
1839
|
+
{
|
|
1840
|
+
"id": record["case"]["id"],
|
|
1841
|
+
"query": query_text,
|
|
1842
|
+
"component": "classifier",
|
|
1843
|
+
"error": str(exc),
|
|
1844
|
+
}
|
|
1845
|
+
)
|
|
1846
|
+
predictions.append(set(_ordered_capabilities(merged_caps)))
|
|
1847
|
+
return predictions, summaries, component_failures
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _model_failure_result(model: str, start: float, error: str, error_type: str = "provider_error") -> dict[str, Any]:
|
|
1851
|
+
return {
|
|
1852
|
+
"model": model,
|
|
1853
|
+
"ok": False,
|
|
1854
|
+
"availability": "failed",
|
|
1855
|
+
"error_type": error_type,
|
|
1856
|
+
"error": error,
|
|
1857
|
+
"dimension": 0,
|
|
1858
|
+
"latency_ms": 0.0,
|
|
1859
|
+
"semantic_macro_f1": 0.0,
|
|
1860
|
+
"full_route_macro_f1": 0.0,
|
|
1861
|
+
"recommended_threshold": None,
|
|
1862
|
+
"recommended_margin": None,
|
|
1863
|
+
"confusion_matrix": {},
|
|
1864
|
+
"semantic_failures": [],
|
|
1865
|
+
"full_route_failures": [],
|
|
1866
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
async def _evaluate_calibration_model(model: str, dataset: list[dict[str, Any]], labels: list[str]) -> dict[str, Any]:
|
|
1871
|
+
start = time.time()
|
|
1872
|
+
proxy = _CalibrationConfigProxy(
|
|
1873
|
+
config,
|
|
1874
|
+
model,
|
|
1875
|
+
_configured_embedding_threshold(),
|
|
1876
|
+
_configured_embedding_margin(),
|
|
1877
|
+
)
|
|
1878
|
+
router = IntentRouter(proxy)
|
|
1879
|
+
if not router._embeddings_configured():
|
|
1880
|
+
return _model_failure_result(
|
|
1881
|
+
model,
|
|
1882
|
+
start,
|
|
1883
|
+
"INTENT_EMBEDDING_API_URL and INTENT_EMBEDDING_API_KEY must be configured before calibration.",
|
|
1884
|
+
"config_error",
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
utterances: list[tuple[str, str]] = []
|
|
1888
|
+
for capability, examples in CAPABILITY_UTTERANCES.items():
|
|
1889
|
+
for example in examples:
|
|
1890
|
+
utterances.append((capability, example))
|
|
1891
|
+
inputs = [item["query"] for item in dataset] + [example for _capability, example in utterances]
|
|
1892
|
+
embed_start = time.time()
|
|
1893
|
+
embeddings = await _embed_in_batches(router, inputs)
|
|
1894
|
+
latency_ms = _elapsed_ms(embed_start)
|
|
1895
|
+
if len(embeddings) != len(inputs):
|
|
1896
|
+
return _model_failure_result(
|
|
1897
|
+
model,
|
|
1898
|
+
start,
|
|
1899
|
+
f"Embedding response returned {len(embeddings)} rows for {len(inputs)} inputs.",
|
|
1900
|
+
)
|
|
1901
|
+
dimension = len(embeddings[0]) if embeddings else 0
|
|
1902
|
+
query_embeddings = embeddings[: len(dataset)]
|
|
1903
|
+
utterance_embeddings = embeddings[len(dataset) :]
|
|
1904
|
+
|
|
1905
|
+
records: list[dict[str, Any]] = []
|
|
1906
|
+
for item, query_embedding in zip(dataset, query_embeddings):
|
|
1907
|
+
scores: dict[str, float] = {}
|
|
1908
|
+
for index, (capability, _example) in enumerate(utterances):
|
|
1909
|
+
score = _cosine_similarity(query_embedding, utterance_embeddings[index])
|
|
1910
|
+
scores[capability] = max(scores.get(capability, 0.0), score)
|
|
1911
|
+
records.append({"case": item, "scores": scores})
|
|
1912
|
+
|
|
1913
|
+
expected = [set(item["expected_capabilities"]) for item in dataset]
|
|
1914
|
+
best = _select_semantic_parameters(records, expected, labels)
|
|
1915
|
+
semantic_predictions, semantic_summaries = _semantic_predictions(records, best["threshold"], best["margin"])
|
|
1916
|
+
semantic_metrics = _macro_f1(expected, semantic_predictions, labels)
|
|
1917
|
+
full_predictions, full_summaries, component_failures = await _full_route_predictions(
|
|
1918
|
+
records,
|
|
1919
|
+
best["threshold"],
|
|
1920
|
+
best["margin"],
|
|
1921
|
+
model,
|
|
1922
|
+
)
|
|
1923
|
+
full_metrics = _macro_f1(expected, full_predictions, labels)
|
|
1924
|
+
|
|
1925
|
+
return {
|
|
1926
|
+
"model": model,
|
|
1927
|
+
"ok": True,
|
|
1928
|
+
"availability": "ok",
|
|
1929
|
+
"dimension": dimension,
|
|
1930
|
+
"latency_ms": latency_ms,
|
|
1931
|
+
"semantic_macro_f1": semantic_metrics["macro_f1"],
|
|
1932
|
+
"semantic_per_label_f1": semantic_metrics["per_label_f1"],
|
|
1933
|
+
"full_route_macro_f1": full_metrics["macro_f1"],
|
|
1934
|
+
"full_route_per_label_f1": full_metrics["per_label_f1"],
|
|
1935
|
+
"recommended_threshold": round(float(best["threshold"]), 3),
|
|
1936
|
+
"recommended_margin": round(float(best["margin"]), 3),
|
|
1937
|
+
"recommendation_basis": "semantic_macro_f1",
|
|
1938
|
+
"confusion_matrix": _confusion_matrix(expected, semantic_predictions),
|
|
1939
|
+
"full_route_confusion_matrix": _confusion_matrix(expected, full_predictions),
|
|
1940
|
+
"semantic_failures": _representative_failures(records, expected, semantic_predictions, semantic_summaries),
|
|
1941
|
+
"full_route_failures": _representative_failures(records, expected, full_predictions, full_summaries),
|
|
1942
|
+
"component_failures": component_failures,
|
|
1943
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
async def route_calibrate(models: str = "") -> dict[str, Any]:
|
|
1948
|
+
start = time.time()
|
|
1949
|
+
selected_models = _parse_calibration_models(models)
|
|
1950
|
+
dataset = _route_calibration_dataset()
|
|
1951
|
+
labels = [*sorted(ROUTABLE_CAPABILITIES), "none"]
|
|
1952
|
+
results: list[dict[str, Any]] = []
|
|
1953
|
+
for model in selected_models:
|
|
1954
|
+
try:
|
|
1955
|
+
results.append(await _evaluate_calibration_model(model, dataset, labels))
|
|
1956
|
+
except Exception as exc:
|
|
1957
|
+
results.append(_model_failure_result(model, start, str(exc)))
|
|
1958
|
+
|
|
1959
|
+
successful = [item for item in results if item.get("ok")]
|
|
1960
|
+
failed_models = [item.get("model") for item in results if not item.get("ok")]
|
|
1961
|
+
recommended = None
|
|
1962
|
+
if successful:
|
|
1963
|
+
recommended = max(
|
|
1964
|
+
successful,
|
|
1965
|
+
key=lambda item: (
|
|
1966
|
+
float(item.get("semantic_macro_f1") or 0.0),
|
|
1967
|
+
float(item.get("full_route_macro_f1") or 0.0),
|
|
1968
|
+
-float(item.get("latency_ms") or 0.0),
|
|
1969
|
+
),
|
|
1970
|
+
)
|
|
1971
|
+
ok = bool(successful)
|
|
1972
|
+
data: dict[str, Any] = {
|
|
1973
|
+
"ok": ok,
|
|
1974
|
+
"metric": "semantic_macro_f1",
|
|
1975
|
+
"primary_metric": "semantic_macro_f1",
|
|
1976
|
+
"full_route_metric_role": "validation",
|
|
1977
|
+
"models": selected_models,
|
|
1978
|
+
"model_results": results,
|
|
1979
|
+
"failed_models": failed_models,
|
|
1980
|
+
"dataset_size": len(dataset),
|
|
1981
|
+
"dataset_counts": {label: len(queries) for label, queries in ROUTE_CALIBRATION_QUERIES.items()},
|
|
1982
|
+
"capabilities": sorted(ROUTABLE_CAPABILITIES),
|
|
1983
|
+
"labels": labels,
|
|
1984
|
+
"default_threshold": _configured_embedding_threshold(),
|
|
1985
|
+
"default_margin": _configured_embedding_margin(),
|
|
1986
|
+
"embedding_model": config.intent_embedding_model,
|
|
1987
|
+
"recommended_model": recommended.get("model") if recommended else "",
|
|
1988
|
+
"recommended_threshold": recommended.get("recommended_threshold") if recommended else None,
|
|
1989
|
+
"recommended_margin": recommended.get("recommended_margin") if recommended else None,
|
|
1990
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1991
|
+
}
|
|
1992
|
+
if ok:
|
|
1993
|
+
data["error_type"] = ""
|
|
1994
|
+
data["error"] = ""
|
|
1995
|
+
else:
|
|
1996
|
+
error_types = {
|
|
1997
|
+
str(item.get("error_type") or "provider_error")
|
|
1998
|
+
for item in results
|
|
1999
|
+
if not item.get("ok")
|
|
2000
|
+
}
|
|
2001
|
+
data["error_type"] = "config_error" if "config_error" in error_types else "provider_error"
|
|
2002
|
+
data["error"] = "No embedding model could be calibrated. See model_results for per-model errors."
|
|
2003
|
+
return data
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _primary_search_exception_result(
|
|
2007
|
+
start: float,
|
|
2008
|
+
session_id: str,
|
|
2009
|
+
query: str,
|
|
2010
|
+
primary_api_mode: str,
|
|
2011
|
+
provider_name: str,
|
|
2012
|
+
exc: BaseException,
|
|
2013
|
+
) -> dict[str, Any]:
|
|
2014
|
+
if isinstance(exc, (httpx.TimeoutException, asyncio.TimeoutError, TimeoutError)):
|
|
2015
|
+
return _primary_search_error_result(
|
|
2016
|
+
start,
|
|
2017
|
+
session_id,
|
|
2018
|
+
query,
|
|
2019
|
+
primary_api_mode,
|
|
2020
|
+
"network_error",
|
|
2021
|
+
f"{provider_name} 请求超时: {str(exc)}",
|
|
2022
|
+
)
|
|
2023
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
2024
|
+
body = exc.response.text[:300] if exc.response is not None else str(exc)
|
|
2025
|
+
status = exc.response.status_code if exc.response is not None else "unknown"
|
|
2026
|
+
return _primary_search_error_result(
|
|
2027
|
+
start,
|
|
2028
|
+
session_id,
|
|
2029
|
+
query,
|
|
2030
|
+
primary_api_mode,
|
|
2031
|
+
"network_error",
|
|
2032
|
+
f"{provider_name} HTTP {status}: {body}",
|
|
2033
|
+
)
|
|
2034
|
+
if isinstance(exc, httpx.RequestError):
|
|
2035
|
+
return _primary_search_error_result(
|
|
2036
|
+
start,
|
|
2037
|
+
session_id,
|
|
2038
|
+
query,
|
|
2039
|
+
primary_api_mode,
|
|
2040
|
+
"network_error",
|
|
2041
|
+
f"{provider_name} 网络错误: {str(exc)}",
|
|
2042
|
+
)
|
|
2043
|
+
return _primary_search_error_result(
|
|
2044
|
+
start,
|
|
2045
|
+
session_id,
|
|
2046
|
+
query,
|
|
2047
|
+
primary_api_mode,
|
|
2048
|
+
"runtime_error",
|
|
2049
|
+
f"{provider_name} 运行错误: {str(exc)}",
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _primary_search_error_result(
|
|
2054
|
+
start: float,
|
|
2055
|
+
session_id: str,
|
|
2056
|
+
query: str,
|
|
2057
|
+
primary_api_mode: str,
|
|
2058
|
+
error_type: str,
|
|
2059
|
+
error: str,
|
|
2060
|
+
) -> dict[str, Any]:
|
|
2061
|
+
return {
|
|
2062
|
+
"ok": False,
|
|
2063
|
+
"error_type": error_type,
|
|
2064
|
+
"error": error,
|
|
2065
|
+
"session_id": session_id,
|
|
2066
|
+
"query": query,
|
|
2067
|
+
"primary_api_mode": primary_api_mode,
|
|
2068
|
+
"content": "",
|
|
2069
|
+
"sources": [],
|
|
2070
|
+
"sources_count": 0,
|
|
2071
|
+
"primary_sources": [],
|
|
2072
|
+
"primary_sources_count": 0,
|
|
2073
|
+
"extra_sources": [],
|
|
2074
|
+
"extra_sources_count": 0,
|
|
2075
|
+
"source_warning": "",
|
|
2076
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
async def fetch(
|
|
2081
|
+
url: str,
|
|
2082
|
+
*,
|
|
2083
|
+
fallback: str = "auto",
|
|
2084
|
+
preferred_order: list[str] | None = None,
|
|
2085
|
+
) -> dict[str, Any]:
|
|
2086
|
+
start = time.time()
|
|
2087
|
+
fetch_result, attempts = await _run_web_fetch_fallback(
|
|
2088
|
+
url,
|
|
2089
|
+
fallback=fallback,
|
|
2090
|
+
preferred_order=preferred_order,
|
|
2091
|
+
)
|
|
2092
|
+
if fetch_result:
|
|
2093
|
+
return {
|
|
2094
|
+
**fetch_result,
|
|
2095
|
+
"provider_attempts": attempts,
|
|
2096
|
+
"fallback_used": _fallback_used(attempts),
|
|
2097
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
if not (config.tavily_api_key or config.jina_api_key or config.zhipu_mcp_api_key or config.firecrawl_api_key):
|
|
2101
|
+
error = "TAVILY_API_KEY、JINA_API_KEY、ZHIPU_MCP_API_KEY 和 FIRECRAWL_API_KEY 均未配置"
|
|
2102
|
+
error_type = "config_error"
|
|
2103
|
+
else:
|
|
2104
|
+
error = "所有提取服务均未能获取内容"
|
|
2105
|
+
error_type = "network_error"
|
|
2106
|
+
return {
|
|
2107
|
+
"ok": False,
|
|
2108
|
+
"url": url,
|
|
2109
|
+
"provider": "",
|
|
2110
|
+
"content": "",
|
|
2111
|
+
"error_type": error_type,
|
|
2112
|
+
"error": error,
|
|
2113
|
+
"provider_attempts": attempts,
|
|
2114
|
+
"fallback_used": _fallback_used(attempts),
|
|
2115
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
async def map_site(
|
|
2120
|
+
url: str,
|
|
2121
|
+
instructions: str = "",
|
|
2122
|
+
max_depth: int = 1,
|
|
2123
|
+
max_breadth: int = 20,
|
|
2124
|
+
limit: int = 50,
|
|
2125
|
+
timeout: int = 150,
|
|
2126
|
+
) -> dict[str, Any]:
|
|
2127
|
+
start = time.time()
|
|
2128
|
+
result = await call_tavily_map(url, instructions, max_depth, max_breadth, limit, timeout)
|
|
2129
|
+
result.setdefault("url", url)
|
|
2130
|
+
result.setdefault("elapsed_ms", _elapsed_ms(start))
|
|
2131
|
+
return result
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
async def exa_search(
|
|
2135
|
+
query: str,
|
|
2136
|
+
num_results: int = 5,
|
|
2137
|
+
search_type: str = "neural",
|
|
2138
|
+
include_text: bool = False,
|
|
2139
|
+
include_highlights: bool = False,
|
|
2140
|
+
start_published_date: str = "",
|
|
2141
|
+
include_domains: str | list[str] | tuple[str, ...] = "",
|
|
2142
|
+
exclude_domains: str | list[str] | tuple[str, ...] = "",
|
|
2143
|
+
category: str = "",
|
|
2144
|
+
) -> dict[str, Any]:
|
|
2145
|
+
api_key = config.exa_api_key
|
|
2146
|
+
if not api_key:
|
|
2147
|
+
return {
|
|
2148
|
+
"ok": False,
|
|
2149
|
+
"error_type": "config_error",
|
|
2150
|
+
"error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
|
|
2154
|
+
include_domain_list = _normalize_domain_filter(include_domains)
|
|
2155
|
+
exclude_domain_list = _normalize_domain_filter(exclude_domains)
|
|
2156
|
+
|
|
2157
|
+
raw = await provider.search(
|
|
2158
|
+
query=query,
|
|
2159
|
+
num_results=num_results,
|
|
2160
|
+
search_type=search_type,
|
|
2161
|
+
include_text=include_text,
|
|
2162
|
+
include_highlights=include_highlights,
|
|
2163
|
+
start_published_date=start_published_date or None,
|
|
2164
|
+
include_domains=include_domain_list,
|
|
2165
|
+
exclude_domains=exclude_domain_list,
|
|
2166
|
+
category=category or None,
|
|
2167
|
+
)
|
|
2168
|
+
try:
|
|
2169
|
+
data = json.loads(raw)
|
|
2170
|
+
except json.JSONDecodeError:
|
|
2171
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2172
|
+
if not data.get("ok", False):
|
|
2173
|
+
data.setdefault("error_type", "network_error")
|
|
2174
|
+
return data
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
async def _decode_provider_json(raw: str, provider: str = "provider") -> dict[str, Any]:
|
|
2178
|
+
try:
|
|
2179
|
+
return json.loads(raw)
|
|
2180
|
+
except json.JSONDecodeError:
|
|
2181
|
+
return {"ok": False, "provider": provider, "error_type": "parse_error", "error": raw}
|
|
2182
|
+
def _zhipu_mcp_search_provider() -> ZhipuMCPProvider:
|
|
2183
|
+
return ZhipuMCPProvider(
|
|
2184
|
+
config.zhipu_mcp_search_api_url,
|
|
2185
|
+
config.zhipu_mcp_api_key or "",
|
|
2186
|
+
config.zhipu_mcp_timeout,
|
|
2187
|
+
provider_id="zhipu-mcp",
|
|
2188
|
+
)
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
def _zhipu_mcp_reader_provider() -> ZhipuMCPProvider:
|
|
2192
|
+
return ZhipuMCPProvider(
|
|
2193
|
+
config.zhipu_mcp_reader_api_url,
|
|
2194
|
+
config.zhipu_mcp_api_key or "",
|
|
2195
|
+
config.zhipu_mcp_timeout,
|
|
2196
|
+
provider_id="zhipu-mcp-reader",
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _zhipu_mcp_zread_provider() -> ZhipuMCPProvider:
|
|
2201
|
+
return ZhipuMCPProvider(
|
|
2202
|
+
config.zhipu_mcp_zread_api_url,
|
|
2203
|
+
config.zhipu_mcp_api_key or "",
|
|
2204
|
+
config.zhipu_mcp_timeout,
|
|
2205
|
+
provider_id="zhipu-mcp-zread",
|
|
2206
|
+
)
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
async def jina_fetch(url: str) -> dict[str, Any]:
|
|
2210
|
+
return await call_jina_reader(url)
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
async def zhipu_mcp_search(query: str, count: int = 5) -> dict[str, Any]:
|
|
2214
|
+
return await _decode_provider_json(await _zhipu_mcp_search_provider().web_search(query, count=count), provider="zhipu-mcp")
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
async def zhipu_mcp_reader(url: str) -> dict[str, Any]:
|
|
2218
|
+
return await _decode_provider_json(await _zhipu_mcp_reader_provider().web_reader(url), provider="zhipu-mcp-reader")
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
async def zhipu_mcp_search_doc(repo: str, query: str, max_results: int = 5) -> dict[str, Any]:
|
|
2222
|
+
return await _decode_provider_json(await _zhipu_mcp_zread_provider().search_doc(repo, query, max_results=max_results), provider="zhipu-mcp-zread")
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
async def zhipu_mcp_repo_structure(repo: str, path: str = "", ref: str = "") -> dict[str, Any]:
|
|
2226
|
+
return await _decode_provider_json(await _zhipu_mcp_zread_provider().get_repo_structure(repo, path=path, ref=ref), provider="zhipu-mcp-zread")
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
async def zhipu_mcp_read_file(repo: str, path: str, ref: str = "") -> dict[str, Any]:
|
|
2230
|
+
return await _decode_provider_json(await _zhipu_mcp_zread_provider().read_file(repo, path, ref=ref), provider="zhipu-mcp-zread")
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
async def exa_find_similar(url: str, num_results: int = 5) -> dict[str, Any]:
|
|
2234
|
+
api_key = config.exa_api_key
|
|
2235
|
+
if not api_key:
|
|
2236
|
+
return {
|
|
2237
|
+
"ok": False,
|
|
2238
|
+
"error_type": "config_error",
|
|
2239
|
+
"error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
|
|
2243
|
+
raw = await provider.find_similar(url=url, num_results=num_results)
|
|
2244
|
+
try:
|
|
2245
|
+
data = json.loads(raw)
|
|
2246
|
+
except json.JSONDecodeError:
|
|
2247
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2248
|
+
if not data.get("ok", False):
|
|
2249
|
+
data.setdefault("error_type", "network_error")
|
|
2250
|
+
return data
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
async def zhipu_search(
|
|
2254
|
+
query: str,
|
|
2255
|
+
count: int = 10,
|
|
2256
|
+
search_engine: str = "",
|
|
2257
|
+
search_recency_filter: str = "noLimit",
|
|
2258
|
+
search_domain_filter: str = "",
|
|
2259
|
+
content_size: str = "medium",
|
|
2260
|
+
) -> dict[str, Any]:
|
|
2261
|
+
api_key = config.zhipu_api_key
|
|
2262
|
+
if not api_key:
|
|
2263
|
+
return {
|
|
2264
|
+
"ok": False,
|
|
2265
|
+
"error_type": "config_error",
|
|
2266
|
+
"error": "ZHIPU_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set ZHIPU_API_KEY <key>`。",
|
|
2267
|
+
}
|
|
2268
|
+
provider = ZhipuWebSearchProvider(
|
|
2269
|
+
config.zhipu_api_url,
|
|
2270
|
+
api_key,
|
|
2271
|
+
search_engine or config.zhipu_search_engine,
|
|
2272
|
+
config.zhipu_timeout,
|
|
2273
|
+
)
|
|
2274
|
+
raw = await provider.search(
|
|
2275
|
+
query=query,
|
|
2276
|
+
count=count,
|
|
2277
|
+
search_engine=search_engine or None,
|
|
2278
|
+
search_recency_filter=search_recency_filter,
|
|
2279
|
+
search_domain_filter=search_domain_filter,
|
|
2280
|
+
content_size=content_size,
|
|
2281
|
+
)
|
|
2282
|
+
try:
|
|
2283
|
+
data = json.loads(raw)
|
|
2284
|
+
except json.JSONDecodeError:
|
|
2285
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2286
|
+
if not data.get("ok", False):
|
|
2287
|
+
data.setdefault("error_type", "network_error")
|
|
2288
|
+
return data
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
async def context7_library(name: str, query: str = "") -> dict[str, Any]:
|
|
2292
|
+
api_key = config.context7_api_key
|
|
2293
|
+
if not api_key:
|
|
2294
|
+
return {
|
|
2295
|
+
"ok": False,
|
|
2296
|
+
"error_type": "config_error",
|
|
2297
|
+
"error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
|
|
2298
|
+
}
|
|
2299
|
+
provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
|
|
2300
|
+
raw = await provider.library(name, query)
|
|
2301
|
+
try:
|
|
2302
|
+
data = json.loads(raw)
|
|
2303
|
+
except json.JSONDecodeError:
|
|
2304
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2305
|
+
if not data.get("ok", False):
|
|
2306
|
+
data.setdefault("error_type", "network_error")
|
|
2307
|
+
return data
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
async def context7_docs(library_id: str, query: str) -> dict[str, Any]:
|
|
2311
|
+
api_key = config.context7_api_key
|
|
2312
|
+
if not api_key:
|
|
2313
|
+
return {
|
|
2314
|
+
"ok": False,
|
|
2315
|
+
"error_type": "config_error",
|
|
2316
|
+
"error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
|
|
2317
|
+
}
|
|
2318
|
+
provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
|
|
2319
|
+
raw = await provider.docs(library_id, query)
|
|
2320
|
+
try:
|
|
2321
|
+
data = json.loads(raw)
|
|
2322
|
+
except json.JSONDecodeError:
|
|
2323
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2324
|
+
if not data.get("ok", False):
|
|
2325
|
+
data.setdefault("error_type", "network_error")
|
|
2326
|
+
return data
|
|
2327
|
+
|
|
2328
|
+
|
|
2329
|
+
async def search_answer(query: str, *, stream: bool | None = None, timeout_seconds: float | None = None, debug: bool = False) -> dict[str, Any]:
|
|
2330
|
+
start = time.time()
|
|
2331
|
+
candidates, _ = operation_candidates("search.answer")
|
|
2332
|
+
if not candidates:
|
|
2333
|
+
return _operation_envelope(
|
|
2334
|
+
"search",
|
|
2335
|
+
"answer",
|
|
2336
|
+
ok=False,
|
|
2337
|
+
start=start,
|
|
2338
|
+
error_type="config_error",
|
|
2339
|
+
error="No configured provider supports search.answer",
|
|
2340
|
+
)
|
|
2341
|
+
policy = operation_policy("search.answer")
|
|
2342
|
+
budget = min(timeout_seconds, policy["timeout"]) if timeout_seconds else policy["timeout"]
|
|
2343
|
+
data = await search(
|
|
2344
|
+
query,
|
|
2345
|
+
stream=stream,
|
|
2346
|
+
timeout_seconds=budget,
|
|
2347
|
+
providers=",".join(candidates),
|
|
2348
|
+
fallback="auto" if policy["fallback"] else "off",
|
|
2349
|
+
enforce_minimum_profile=False,
|
|
2350
|
+
)
|
|
2351
|
+
return _operation_envelope(
|
|
2352
|
+
"search",
|
|
2353
|
+
"answer",
|
|
2354
|
+
ok=bool(data.get("ok")),
|
|
2355
|
+
start=start,
|
|
2356
|
+
content=data.get("content", ""),
|
|
2357
|
+
sources=data.get("sources") or [],
|
|
2358
|
+
error_type=data.get("error_type", ""),
|
|
2359
|
+
error=data.get("error", ""),
|
|
2360
|
+
extra=data,
|
|
2361
|
+
debug=debug,
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
async def search_sources(
|
|
2366
|
+
query: str,
|
|
2367
|
+
*,
|
|
2368
|
+
limit: int = 5,
|
|
2369
|
+
mode: str = "auto",
|
|
2370
|
+
start_published_date: str = "",
|
|
2371
|
+
include_domains: list[str] | None = None,
|
|
2372
|
+
exclude_domains: list[str] | None = None,
|
|
2373
|
+
category: str = "",
|
|
2374
|
+
include_text: bool = False,
|
|
2375
|
+
include_highlights: bool = False,
|
|
2376
|
+
debug: bool = False,
|
|
2377
|
+
) -> dict[str, Any]:
|
|
2378
|
+
start = time.time()
|
|
2379
|
+
required: set[str] = set()
|
|
2380
|
+
if mode in {"semantic", "keyword"}:
|
|
2381
|
+
required.add(mode)
|
|
2382
|
+
if start_published_date:
|
|
2383
|
+
required.add("time")
|
|
2384
|
+
if include_domains or exclude_domains:
|
|
2385
|
+
required.add("domains")
|
|
2386
|
+
if category:
|
|
2387
|
+
required.add("category")
|
|
2388
|
+
if include_text:
|
|
2389
|
+
required.add("text")
|
|
2390
|
+
if include_highlights:
|
|
2391
|
+
required.add("highlights")
|
|
2392
|
+
candidates, missing = operation_candidates("search.sources", required_features=required)
|
|
2393
|
+
if not candidates:
|
|
2394
|
+
error_type = "capability_error" if missing else "config_error"
|
|
2395
|
+
detail = f"; missing features: {', '.join(sorted(missing))}" if missing else ""
|
|
2396
|
+
return _operation_envelope(
|
|
2397
|
+
"search",
|
|
2398
|
+
"sources",
|
|
2399
|
+
ok=False,
|
|
2400
|
+
start=start,
|
|
2401
|
+
error_type=error_type,
|
|
2402
|
+
error=f"No configured provider supports search.sources{detail}",
|
|
2403
|
+
)
|
|
2404
|
+
|
|
2405
|
+
async def invoke(provider: str) -> dict[str, Any]:
|
|
2406
|
+
if provider == "exa":
|
|
2407
|
+
data = await exa_search(
|
|
2408
|
+
query,
|
|
2409
|
+
num_results=limit,
|
|
2410
|
+
search_type="neural" if mode == "semantic" else mode,
|
|
2411
|
+
include_text=include_text,
|
|
2412
|
+
include_highlights=include_highlights,
|
|
2413
|
+
start_published_date=start_published_date,
|
|
2414
|
+
include_domains=include_domains or "",
|
|
2415
|
+
exclude_domains=exclude_domains or "",
|
|
2416
|
+
category=category,
|
|
2417
|
+
)
|
|
2418
|
+
results = _normalize_source_results(data.get("results"), provider) if data.get("ok") else []
|
|
2419
|
+
elif provider == "zhipu":
|
|
2420
|
+
data = await zhipu_search(
|
|
2421
|
+
query,
|
|
2422
|
+
count=limit,
|
|
2423
|
+
search_recency_filter=start_published_date or "noLimit",
|
|
2424
|
+
search_domain_filter=(include_domains or [""])[0],
|
|
2425
|
+
)
|
|
2426
|
+
results = _normalize_source_results(data.get("results"), provider) if data.get("ok") else []
|
|
2427
|
+
elif provider == "zhipu-mcp":
|
|
2428
|
+
data = await zhipu_mcp_search(query, count=limit)
|
|
2429
|
+
results = _normalize_source_results(data.get("results"), provider) if data.get("ok") else []
|
|
2430
|
+
elif provider == "tavily":
|
|
2431
|
+
raw = await call_tavily_search(query, limit)
|
|
2432
|
+
data = {"ok": bool(raw)}
|
|
2433
|
+
results = _normalize_source_results(raw, provider)
|
|
2434
|
+
else:
|
|
2435
|
+
raw = await call_firecrawl_search(query, limit)
|
|
2436
|
+
data = {"ok": bool(raw)}
|
|
2437
|
+
results = _normalize_source_results(raw, provider)
|
|
2438
|
+
return {**data, "ok": bool(results), "results": results}
|
|
2439
|
+
|
|
2440
|
+
data, attempts = await _run_operation_candidates("search.sources", candidates, invoke, start=start)
|
|
2441
|
+
if data and data.get("ok"):
|
|
2442
|
+
results = data.get("results") or []
|
|
2443
|
+
return _operation_envelope(
|
|
2444
|
+
"search",
|
|
2445
|
+
"sources",
|
|
2446
|
+
ok=True,
|
|
2447
|
+
start=start,
|
|
2448
|
+
sources=results,
|
|
2449
|
+
extra={"results": results, "provider_attempts": attempts},
|
|
2450
|
+
debug=debug,
|
|
2451
|
+
)
|
|
2452
|
+
return _operation_envelope(
|
|
2453
|
+
"search",
|
|
2454
|
+
"sources",
|
|
2455
|
+
ok=False,
|
|
2456
|
+
start=start,
|
|
2457
|
+
error_type=(data or {}).get("error_type", "network_error"),
|
|
2458
|
+
error=(data or {}).get("error", "All search.sources providers failed"),
|
|
2459
|
+
extra={"provider_attempts": attempts},
|
|
2460
|
+
debug=debug,
|
|
2461
|
+
)
|
|
2462
|
+
async def search_similar(url: str, *, limit: int = 5, debug: bool = False) -> dict[str, Any]:
|
|
2463
|
+
start = time.time()
|
|
2464
|
+
candidates, _ = operation_candidates("search.similar")
|
|
2465
|
+
if not candidates:
|
|
2466
|
+
return _operation_envelope("search", "similar", ok=False, start=start, error_type="config_error", error="No configured provider supports search.similar")
|
|
2467
|
+
try:
|
|
2468
|
+
data = await _await_operation(
|
|
2469
|
+
exa_find_similar(url, num_results=limit),
|
|
2470
|
+
start=start,
|
|
2471
|
+
timeout=operation_policy("search.similar")["timeout"],
|
|
2472
|
+
)
|
|
2473
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2474
|
+
data = {"ok": False, "error_type": "timeout", "error": "search.similar timed out"}
|
|
2475
|
+
results = _normalize_source_results(data.get("results"), "exa") if data.get("ok") else []
|
|
2476
|
+
return _operation_envelope("search", "similar", ok=bool(results), start=start, sources=results, error_type=data.get("error_type", "network_error"), error=data.get("error", ""), extra={"results": results}, debug=debug)
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
async def docs_resolve(name: str, query: str = "", *, debug: bool = False) -> dict[str, Any]:
|
|
2480
|
+
start = time.time()
|
|
2481
|
+
candidates, _ = operation_candidates("docs.resolve")
|
|
2482
|
+
if not candidates:
|
|
2483
|
+
return _operation_envelope("docs", "resolve", ok=False, start=start, error_type="config_error", error="No configured provider supports docs.resolve")
|
|
2484
|
+
try:
|
|
2485
|
+
data = await _await_operation(
|
|
2486
|
+
context7_library(name, query),
|
|
2487
|
+
start=start,
|
|
2488
|
+
timeout=operation_policy("docs.resolve")["timeout"],
|
|
2489
|
+
)
|
|
2490
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2491
|
+
data = {"ok": False, "error_type": "timeout", "error": "docs.resolve timed out"}
|
|
2492
|
+
rows = data.get("results") or []
|
|
2493
|
+
candidates_out = [{"id": row.get("id", ""), "title": row.get("title") or row.get("name") or row.get("id", ""), "description": row.get("description", "")} for row in rows]
|
|
2494
|
+
return _operation_envelope("docs", "resolve", ok=bool(data.get("ok")), start=start, error_type=data.get("error_type", ""), error=data.get("error", ""), extra={"candidates": candidates_out}, debug=debug)
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
async def docs_search(query: str, *, source: str = "", debug: bool = False) -> dict[str, Any]:
|
|
2498
|
+
start = time.time()
|
|
2499
|
+
candidates, _ = operation_candidates("docs.search")
|
|
2500
|
+
if source and "/" in source:
|
|
2501
|
+
candidates = [provider for provider in candidates if provider == "zhipu-mcp-zread"] + [
|
|
2502
|
+
provider for provider in candidates if provider != "zhipu-mcp-zread"
|
|
2503
|
+
]
|
|
2504
|
+
if not candidates:
|
|
2505
|
+
return _operation_envelope(
|
|
2506
|
+
"docs",
|
|
2507
|
+
"search",
|
|
2508
|
+
ok=False,
|
|
2509
|
+
start=start,
|
|
2510
|
+
error_type="config_error",
|
|
2511
|
+
error="No configured provider supports docs.search",
|
|
2512
|
+
extra={"results": []},
|
|
2513
|
+
)
|
|
2514
|
+
|
|
2515
|
+
async def invoke(provider: str) -> dict[str, Any]:
|
|
2516
|
+
if provider == "zhipu-mcp-zread" and source:
|
|
2517
|
+
data = await zhipu_mcp_search_doc(source, query)
|
|
2518
|
+
results = _normalize_source_results(data.get("results"), provider) if data.get("ok") else []
|
|
2519
|
+
elif provider == "context7":
|
|
2520
|
+
library_id = source
|
|
2521
|
+
if not library_id:
|
|
2522
|
+
resolved = await context7_library(query, query)
|
|
2523
|
+
library_id = next((row.get("id", "") for row in resolved.get("results", []) if row.get("id")), "")
|
|
2524
|
+
data = await context7_docs(library_id, query) if library_id else {
|
|
2525
|
+
"ok": False,
|
|
2526
|
+
"error": "library not resolved",
|
|
2527
|
+
}
|
|
2528
|
+
results = []
|
|
2529
|
+
if data.get("ok"):
|
|
2530
|
+
for index, row in enumerate(data.get("results") or [], 1):
|
|
2531
|
+
text = row.get("content") or row.get("code") or row.get("description") or json.dumps(row, ensure_ascii=False)
|
|
2532
|
+
results.append(
|
|
2533
|
+
{
|
|
2534
|
+
"url": f"context7:{library_id}#{index}",
|
|
2535
|
+
"title": row.get("title") or library_id,
|
|
2536
|
+
"description": str(text)[:500],
|
|
2537
|
+
"snippet": str(text)[:500],
|
|
2538
|
+
"provider": provider,
|
|
2539
|
+
}
|
|
2540
|
+
)
|
|
2541
|
+
if not results and data.get("content"):
|
|
2542
|
+
results.append(
|
|
2543
|
+
{
|
|
2544
|
+
"url": f"context7:{library_id}",
|
|
2545
|
+
"title": library_id,
|
|
2546
|
+
"description": data["content"][:500],
|
|
2547
|
+
"snippet": data["content"][:500],
|
|
2548
|
+
"provider": provider,
|
|
2549
|
+
}
|
|
2550
|
+
)
|
|
2551
|
+
elif provider == "exa":
|
|
2552
|
+
data = await exa_search(
|
|
2553
|
+
query,
|
|
2554
|
+
num_results=5,
|
|
2555
|
+
include_highlights=True,
|
|
2556
|
+
include_domains=[source] if source and "." in source and "/" not in source else "",
|
|
2557
|
+
)
|
|
2558
|
+
results = _normalize_source_results(data.get("results"), provider) if data.get("ok") else []
|
|
2559
|
+
else:
|
|
2560
|
+
return {"ok": False, "error_type": "capability_error", "error": f"{provider} cannot satisfy docs.search"}
|
|
2561
|
+
return {**data, "ok": bool(results), "results": results}
|
|
2562
|
+
|
|
2563
|
+
data, attempts = await _run_operation_candidates("docs.search", candidates, invoke, start=start)
|
|
2564
|
+
if data and data.get("ok"):
|
|
2565
|
+
results = data.get("results") or []
|
|
2566
|
+
return _operation_envelope(
|
|
2567
|
+
"docs",
|
|
2568
|
+
"search",
|
|
2569
|
+
ok=True,
|
|
2570
|
+
start=start,
|
|
2571
|
+
sources=results,
|
|
2572
|
+
extra={"results": results, "provider_attempts": attempts},
|
|
2573
|
+
debug=debug,
|
|
2574
|
+
)
|
|
2575
|
+
return _operation_envelope(
|
|
2576
|
+
"docs",
|
|
2577
|
+
"search",
|
|
2578
|
+
ok=False,
|
|
2579
|
+
start=start,
|
|
2580
|
+
error_type=(data or {}).get("error_type", "network_error"),
|
|
2581
|
+
error=(data or {}).get("error", "No docs.search provider returned results"),
|
|
2582
|
+
extra={"results": [], "provider_attempts": attempts},
|
|
2583
|
+
debug=debug,
|
|
2584
|
+
)
|
|
2585
|
+
async def docs_tree(repo: str, *, path: str = "", ref: str = "", debug: bool = False) -> dict[str, Any]:
|
|
2586
|
+
start = time.time()
|
|
2587
|
+
candidates, _ = operation_candidates("docs.tree")
|
|
2588
|
+
if not candidates:
|
|
2589
|
+
return _operation_envelope("docs", "tree", ok=False, start=start, error_type="config_error", error="No configured provider supports docs.tree", extra={"entries": []})
|
|
2590
|
+
try:
|
|
2591
|
+
data = await _await_operation(
|
|
2592
|
+
zhipu_mcp_repo_structure(repo, path=path, ref=ref),
|
|
2593
|
+
start=start,
|
|
2594
|
+
timeout=operation_policy("docs.tree")["timeout"],
|
|
2595
|
+
)
|
|
2596
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2597
|
+
data = {"ok": False, "error_type": "timeout", "error": "docs.tree timed out"}
|
|
2598
|
+
entries = data.get("results") or []
|
|
2599
|
+
return _operation_envelope("docs", "tree", ok=bool(data.get("ok")), start=start, content=data.get("content", ""), error_type=data.get("error_type", ""), error=data.get("error", ""), extra={"entries": entries}, debug=debug)
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
async def docs_read(repo: str, path: str, *, ref: str = "", debug: bool = False) -> dict[str, Any]:
|
|
2603
|
+
start = time.time()
|
|
2604
|
+
candidates, _ = operation_candidates("docs.read")
|
|
2605
|
+
if not candidates:
|
|
2606
|
+
return _operation_envelope("docs", "read", ok=False, start=start, error_type="config_error", error="No configured provider supports docs.read")
|
|
2607
|
+
try:
|
|
2608
|
+
data = await _await_operation(
|
|
2609
|
+
zhipu_mcp_read_file(repo, path, ref=ref),
|
|
2610
|
+
start=start,
|
|
2611
|
+
timeout=operation_policy("docs.read")["timeout"],
|
|
2612
|
+
)
|
|
2613
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2614
|
+
data = {"ok": False, "error_type": "timeout", "error": "docs.read timed out"}
|
|
2615
|
+
return _operation_envelope("docs", "read", ok=bool(data.get("ok")), start=start, content=data.get("content", ""), error_type=data.get("error_type", ""), error=data.get("error", ""), sources=[{"title": path, "url": f"repo:{repo}/{path}", "snippet": ""}], debug=debug)
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
async def fetch_content(url: str, *, debug: bool = False) -> dict[str, Any]:
|
|
2619
|
+
start = time.time()
|
|
2620
|
+
candidates, _ = operation_candidates("fetch.content")
|
|
2621
|
+
if not candidates:
|
|
2622
|
+
return _operation_envelope(
|
|
2623
|
+
"fetch",
|
|
2624
|
+
"content",
|
|
2625
|
+
ok=False,
|
|
2626
|
+
start=start,
|
|
2627
|
+
error_type="config_error",
|
|
2628
|
+
error="No configured provider supports fetch.content",
|
|
2629
|
+
)
|
|
2630
|
+
policy = operation_policy("fetch.content")
|
|
2631
|
+
try:
|
|
2632
|
+
data = await _await_operation(
|
|
2633
|
+
fetch(
|
|
2634
|
+
url,
|
|
2635
|
+
fallback="auto" if policy["fallback"] else "off",
|
|
2636
|
+
preferred_order=candidates,
|
|
2637
|
+
),
|
|
2638
|
+
start=start,
|
|
2639
|
+
timeout=policy["timeout"],
|
|
2640
|
+
)
|
|
2641
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2642
|
+
data = {"ok": False, "error_type": "timeout", "error": "fetch.content timed out"}
|
|
2643
|
+
sources = [{"title": url, "url": url, "snippet": data.get("content", "")[:300]}] if data.get("ok") else []
|
|
2644
|
+
return _operation_envelope("fetch", "content", ok=bool(data.get("ok")), start=start, content=data.get("content", ""), sources=sources, error_type=data.get("error_type", ""), error=data.get("error", ""), extra=data, debug=debug)
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
async def firecrawl_extract(url: str, *, max_length: int = 20000, schema: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
2648
|
+
if not config.firecrawl_api_key:
|
|
2649
|
+
return {"ok": False, "error_type": "config_error", "error": "FIRECRAWL_API_KEY is not configured"}
|
|
2650
|
+
json_format: dict[str, Any] = {"type": "json"}
|
|
2651
|
+
if schema:
|
|
2652
|
+
json_format["schema"] = schema
|
|
2653
|
+
body: dict[str, Any] = {"url": url, "formats": [json_format], "timeout": 60000}
|
|
2654
|
+
try:
|
|
2655
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
2656
|
+
response = await client.post(f"{config.firecrawl_api_url.rstrip('/')}/scrape", headers={"Authorization": f"Bearer {config.firecrawl_api_key}", "Content-Type": "application/json"}, json=body)
|
|
2657
|
+
response.raise_for_status()
|
|
2658
|
+
payload = response.json().get("data", {})
|
|
2659
|
+
raw = json.dumps(payload, ensure_ascii=False)
|
|
2660
|
+
return {"ok": True, "data": payload.get("json", payload), "raw_evidence": raw[:max_length]}
|
|
2661
|
+
except Exception as exc:
|
|
2662
|
+
return {"ok": False, "error_type": "network_error", "error": str(exc)}
|
|
2663
|
+
|
|
2664
|
+
|
|
2665
|
+
async def fetch_extract(url: str, *, max_length: int = 20000, schema: dict[str, Any] | None = None, debug: bool = False) -> dict[str, Any]:
|
|
2666
|
+
start = time.time()
|
|
2667
|
+
candidates, _ = operation_candidates("fetch.extract", required_features={"structured"})
|
|
2668
|
+
if not candidates:
|
|
2669
|
+
return _operation_envelope("fetch", "extract", ok=False, start=start, error_type="config_error", error="No configured provider supports fetch.extract", extra={"data": None})
|
|
2670
|
+
try:
|
|
2671
|
+
data = await _await_operation(
|
|
2672
|
+
firecrawl_extract(url, max_length=max_length, schema=schema),
|
|
2673
|
+
start=start,
|
|
2674
|
+
timeout=operation_policy("fetch.extract")["timeout"],
|
|
2675
|
+
)
|
|
2676
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2677
|
+
data = {"ok": False, "error_type": "timeout", "error": "fetch.extract timed out"}
|
|
2678
|
+
return _operation_envelope("fetch", "extract", ok=bool(data.get("ok")), start=start, error_type=data.get("error_type", ""), error=data.get("error", ""), extra={"data": data.get("data"), "raw_evidence": data.get("raw_evidence", "")}, debug=debug)
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
async def map_site_operation(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
2682
|
+
start = time.time()
|
|
2683
|
+
debug = bool(kwargs.pop("debug", False))
|
|
2684
|
+
candidates, _ = operation_candidates("map.site")
|
|
2685
|
+
if not candidates:
|
|
2686
|
+
return _operation_envelope("map", "site", ok=False, start=start, error_type="config_error", error="No configured provider supports map.site", extra={"entries": []})
|
|
2687
|
+
try:
|
|
2688
|
+
data = await _await_operation(
|
|
2689
|
+
map_site(url, **kwargs),
|
|
2690
|
+
start=start,
|
|
2691
|
+
timeout=operation_policy("map.site")["timeout"],
|
|
2692
|
+
)
|
|
2693
|
+
except (asyncio.TimeoutError, httpx.TimeoutException, TimeoutError):
|
|
2694
|
+
data = {"ok": False, "error_type": "timeout", "error": "map.site timed out"}
|
|
2695
|
+
entries = data.get("results") or []
|
|
2696
|
+
return _operation_envelope("map", "site", ok=bool(data.get("ok")), start=start, error_type=data.get("error_type", ""), error=data.get("error", ""), extra={"entries": entries, "results": entries}, debug=debug)
|
|
2697
|
+
|
|
2698
|
+
|
|
2699
|
+
async def diagnose_operation(capability: str, operation: str = "") -> dict[str, Any]:
|
|
2700
|
+
operations = [name for name in OPERATION_PROFILES if name.startswith(f"{capability}.") and (not operation or name == f"{capability}.{operation}")]
|
|
2701
|
+
checks = []
|
|
2702
|
+
for name in operations:
|
|
2703
|
+
candidates, _ = operation_candidates(name)
|
|
2704
|
+
checks.append({"operation": name, "configured": candidates, "ok": bool(candidates), "single_provider": len(candidates) == 1})
|
|
2705
|
+
return {"ok": bool(checks) and all(item["ok"] for item in checks), "capability": capability, "operation": operation, "checks": checks}
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
async def _test_primary_chat_completion(api_url: str, api_key: str, model: str) -> dict[str, Any]:
|
|
2709
|
+
chat_url = f"{api_url.rstrip('/')}/chat/completions"
|
|
2710
|
+
start = time.time()
|
|
2711
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
2712
|
+
response = await client.post(
|
|
2713
|
+
chat_url,
|
|
2714
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
|
|
2715
|
+
json={
|
|
2716
|
+
"model": model,
|
|
2717
|
+
"messages": [{"role": "user", "content": "Reply with exactly: ok"}],
|
|
2718
|
+
"stream": False,
|
|
2719
|
+
"max_tokens": 8,
|
|
2720
|
+
},
|
|
2721
|
+
)
|
|
2722
|
+
response_time = _elapsed_ms(start)
|
|
2723
|
+
content_type = response.headers.get("content-type", "")
|
|
2724
|
+
if response.status_code != 200:
|
|
2725
|
+
return {
|
|
2726
|
+
"status": "warning",
|
|
2727
|
+
"message": f"HTTP {response.status_code}: {response.text[:100]}",
|
|
2728
|
+
"response_time_ms": response_time,
|
|
2729
|
+
"http_status": response.status_code,
|
|
2730
|
+
"content_type": content_type,
|
|
2731
|
+
"has_content": bool(response.text.strip()),
|
|
2732
|
+
}
|
|
2733
|
+
return {
|
|
2734
|
+
"status": "ok",
|
|
2735
|
+
"message": f"聊天接口可用 (HTTP {response.status_code})",
|
|
2736
|
+
"response_time_ms": response_time,
|
|
2737
|
+
"http_status": response.status_code,
|
|
2738
|
+
"content_type": content_type,
|
|
2739
|
+
"has_content": bool(response.text.strip()),
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _diagnose_check_result(
|
|
2744
|
+
*,
|
|
2745
|
+
name: str,
|
|
2746
|
+
status: str,
|
|
2747
|
+
message: str,
|
|
2748
|
+
start: float,
|
|
2749
|
+
http_status: int | None = None,
|
|
2750
|
+
content_type: str = "",
|
|
2751
|
+
has_content: bool = False,
|
|
2752
|
+
stream: bool | None = None,
|
|
2753
|
+
) -> dict[str, Any]:
|
|
2754
|
+
result: dict[str, Any] = {
|
|
2755
|
+
"name": name,
|
|
2756
|
+
"status": status,
|
|
2757
|
+
"message": message,
|
|
2758
|
+
"response_time_ms": _elapsed_ms(start),
|
|
2759
|
+
"has_content": has_content,
|
|
2760
|
+
}
|
|
2761
|
+
if http_status is not None:
|
|
2762
|
+
result["http_status"] = http_status
|
|
2763
|
+
if content_type:
|
|
2764
|
+
result["content_type"] = content_type
|
|
2765
|
+
if stream is not None:
|
|
2766
|
+
result["stream"] = stream
|
|
2767
|
+
return result
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
def _openai_compatible_diagnosis(quick: dict[str, Any], no_stream: dict[str, Any], stream: dict[str, Any]) -> tuple[bool, str, str]:
|
|
2771
|
+
quick_ok = quick.get("status") == "ok"
|
|
2772
|
+
no_stream_ok = no_stream.get("status") == "ok"
|
|
2773
|
+
stream_ok = stream.get("status") == "ok"
|
|
2774
|
+
search_timeout = no_stream.get("status") == "timeout" or stream.get("status") == "timeout"
|
|
2775
|
+
|
|
2776
|
+
if no_stream_ok and stream_ok:
|
|
2777
|
+
return (
|
|
2778
|
+
True,
|
|
2779
|
+
"OpenAI-compatible 主链路正常。",
|
|
2780
|
+
"真实 search 形态的 stream=false 和 stream=true 都能返回。若用户仍卡住,更可能是调用方、PATH、超时设置或上游偶发波动。",
|
|
2781
|
+
)
|
|
2782
|
+
if stream_ok and not no_stream_ok:
|
|
2783
|
+
return (
|
|
2784
|
+
False,
|
|
2785
|
+
"非流式请求不稳定,流式请求可用。",
|
|
2786
|
+
"建议设置 `OPENAI_COMPATIBLE_STREAM=true`,或临时使用 `smart-search search ... --stream`。",
|
|
2787
|
+
)
|
|
2788
|
+
if no_stream_ok and not stream_ok:
|
|
2789
|
+
return (
|
|
2790
|
+
False,
|
|
2791
|
+
"流式请求不稳定,非流式请求可用。",
|
|
2792
|
+
"建议设置 `OPENAI_COMPATIBLE_STREAM=false`,或临时使用 `smart-search search ... --no-stream`。",
|
|
2793
|
+
)
|
|
2794
|
+
if quick_ok and search_timeout:
|
|
2795
|
+
return (
|
|
2796
|
+
False,
|
|
2797
|
+
"小请求能通,但真实 search 形态超时。",
|
|
2798
|
+
"这通常是上游模型或中转站在处理 smart-search 的完整 prompt 时卡住;建议换模型/中转,或把本诊断报告贴给维护者。",
|
|
2799
|
+
)
|
|
2800
|
+
if quick_ok:
|
|
2801
|
+
return (
|
|
2802
|
+
False,
|
|
2803
|
+
"小请求能通,但真实 search 形态失败。",
|
|
2804
|
+
"这更像上游模型/中转站对 smart-search 请求形态不兼容;建议换模型/中转,或把本诊断报告贴给维护者。",
|
|
2805
|
+
)
|
|
2806
|
+
return (
|
|
2807
|
+
False,
|
|
2808
|
+
"OpenAI-compatible 基础请求不可用。",
|
|
2809
|
+
"请先检查 API URL、API key、模型名和网络;修好后再运行本诊断命令。",
|
|
2810
|
+
)
|
|
2811
|
+
|
|
2812
|
+
|
|
2813
|
+
async def _probe_openai_compatible_search_shape(
|
|
2814
|
+
api_url: str,
|
|
2815
|
+
api_key: str,
|
|
2816
|
+
model: str,
|
|
2817
|
+
*,
|
|
2818
|
+
stream: bool,
|
|
2819
|
+
timeout_seconds: float,
|
|
2820
|
+
) -> dict[str, Any]:
|
|
2821
|
+
name = "真实 search 请求 (stream=true)" if stream else "真实 search 请求 (stream=false)"
|
|
2822
|
+
start = time.time()
|
|
2823
|
+
payload = {
|
|
2824
|
+
"model": model,
|
|
2825
|
+
"messages": [
|
|
2826
|
+
{"role": "system", "content": search_prompt},
|
|
2827
|
+
{"role": "user", "content": get_local_time_info() + "\nping"},
|
|
2828
|
+
],
|
|
2829
|
+
"stream": stream,
|
|
2830
|
+
}
|
|
2831
|
+
headers = {
|
|
2832
|
+
"Authorization": f"Bearer {api_key}",
|
|
2833
|
+
"Content-Type": "application/json",
|
|
2834
|
+
"Accept": "application/json, text/event-stream",
|
|
2835
|
+
"User-Agent": "smart-search/diagnose",
|
|
2836
|
+
}
|
|
2837
|
+
timeout = httpx.Timeout(connect=6.0, read=timeout_seconds, write=10.0, pool=None)
|
|
2838
|
+
try:
|
|
2839
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
|
|
2840
|
+
if stream:
|
|
2841
|
+
async with client.stream(
|
|
2842
|
+
"POST",
|
|
2843
|
+
f"{api_url.rstrip('/')}/chat/completions",
|
|
2844
|
+
headers=headers,
|
|
2845
|
+
json=payload,
|
|
2846
|
+
) as response:
|
|
2847
|
+
content_type = response.headers.get("content-type", "")
|
|
2848
|
+
response.raise_for_status()
|
|
2849
|
+
has_content = False
|
|
2850
|
+
async for line in response.aiter_lines():
|
|
2851
|
+
stripped = line.strip()
|
|
2852
|
+
if not stripped:
|
|
2853
|
+
continue
|
|
2854
|
+
if not stripped.startswith("data:"):
|
|
2855
|
+
continue
|
|
2856
|
+
if stripped in ("data: [DONE]", "data:[DONE]"):
|
|
2857
|
+
continue
|
|
2858
|
+
try:
|
|
2859
|
+
data = json.loads(stripped[5:].lstrip())
|
|
2860
|
+
except json.JSONDecodeError:
|
|
2861
|
+
continue
|
|
2862
|
+
choices = data.get("choices", []) if isinstance(data, dict) else []
|
|
2863
|
+
if not choices:
|
|
2864
|
+
continue
|
|
2865
|
+
delta = choices[0].get("delta", {})
|
|
2866
|
+
if isinstance(delta, dict) and str(delta.get("content") or "").strip():
|
|
2867
|
+
has_content = True
|
|
2868
|
+
break
|
|
2869
|
+
message = choices[0].get("message", {})
|
|
2870
|
+
if isinstance(message, dict) and str(message.get("content") or "").strip():
|
|
2871
|
+
has_content = True
|
|
2872
|
+
break
|
|
2873
|
+
status = "ok" if has_content else "empty"
|
|
2874
|
+
message = f"HTTP {response.status_code}; {'收到流式内容' if has_content else '未收到内容'}"
|
|
2875
|
+
return _diagnose_check_result(
|
|
2876
|
+
name=name,
|
|
2877
|
+
status=status,
|
|
2878
|
+
message=message,
|
|
2879
|
+
start=start,
|
|
2880
|
+
http_status=response.status_code,
|
|
2881
|
+
content_type=content_type,
|
|
2882
|
+
has_content=has_content,
|
|
2883
|
+
stream=stream,
|
|
2884
|
+
)
|
|
2885
|
+
|
|
2886
|
+
response = await client.post(
|
|
2887
|
+
f"{api_url.rstrip('/')}/chat/completions",
|
|
2888
|
+
headers=headers,
|
|
2889
|
+
json=payload,
|
|
2890
|
+
)
|
|
2891
|
+
content_type = response.headers.get("content-type", "")
|
|
2892
|
+
response.raise_for_status()
|
|
2893
|
+
content = await OpenAICompatibleSearchProvider(api_url, api_key, model, stream=False)._parse_completion_response(response)
|
|
2894
|
+
has_content = bool(content.strip())
|
|
2895
|
+
status = "ok" if has_content else "empty"
|
|
2896
|
+
message = f"HTTP {response.status_code}; {'收到内容' if has_content else '返回为空'}"
|
|
2897
|
+
return _diagnose_check_result(
|
|
2898
|
+
name=name,
|
|
2899
|
+
status=status,
|
|
2900
|
+
message=message,
|
|
2901
|
+
start=start,
|
|
2902
|
+
http_status=response.status_code,
|
|
2903
|
+
content_type=content_type,
|
|
2904
|
+
has_content=has_content,
|
|
2905
|
+
stream=stream,
|
|
2906
|
+
)
|
|
2907
|
+
except httpx.TimeoutException as e:
|
|
2908
|
+
return _diagnose_check_result(name=name, status="timeout", message=f"请求超时: {e}", start=start, stream=stream)
|
|
2909
|
+
except httpx.HTTPStatusError as e:
|
|
2910
|
+
body = e.response.text[:200] if e.response is not None else str(e)
|
|
2911
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
2912
|
+
content_type = e.response.headers.get("content-type", "") if e.response is not None else ""
|
|
2913
|
+
return _diagnose_check_result(
|
|
2914
|
+
name=name,
|
|
2915
|
+
status="warning",
|
|
2916
|
+
message=f"HTTP {status_code}: {body}",
|
|
2917
|
+
start=start,
|
|
2918
|
+
http_status=status_code,
|
|
2919
|
+
content_type=content_type,
|
|
2920
|
+
stream=stream,
|
|
2921
|
+
)
|
|
2922
|
+
except httpx.RequestError as e:
|
|
2923
|
+
return _diagnose_check_result(name=name, status="error", message=f"网络错误: {e}", start=start, stream=stream)
|
|
2924
|
+
except Exception as e:
|
|
2925
|
+
return _diagnose_check_result(name=name, status="error", message=f"运行错误: {e}", start=start, stream=stream)
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
async def diagnose_openai_compatible(timeout_seconds: float = 30.0) -> dict[str, Any]:
|
|
2929
|
+
start = time.time()
|
|
2930
|
+
api_url = config.openai_compatible_api_url
|
|
2931
|
+
api_key = config.openai_compatible_api_key
|
|
2932
|
+
model = config.openai_compatible_model
|
|
2933
|
+
info = config.config_path_info()
|
|
2934
|
+
result: dict[str, Any] = {
|
|
2935
|
+
"ok": False,
|
|
2936
|
+
"provider": "openai-compatible",
|
|
2937
|
+
"api_url": api_url or "未配置",
|
|
2938
|
+
"api_key": config._mask_api_key(api_key) if api_key else "未配置",
|
|
2939
|
+
"model": model,
|
|
2940
|
+
"configured_stream": config.openai_compatible_stream,
|
|
2941
|
+
"timeout_seconds": timeout_seconds,
|
|
2942
|
+
"config_file": info.get("config_file", ""),
|
|
2943
|
+
"config_dir_source": info.get("config_dir_source", ""),
|
|
2944
|
+
"checks": [],
|
|
2945
|
+
"next_command": OPENAI_COMPATIBLE_DIAGNOSE_COMMAND,
|
|
2946
|
+
}
|
|
2947
|
+
missing = []
|
|
2948
|
+
if not api_url:
|
|
2949
|
+
missing.append("OPENAI_COMPATIBLE_API_URL")
|
|
2950
|
+
if not api_key:
|
|
2951
|
+
missing.append("OPENAI_COMPATIBLE_API_KEY")
|
|
2952
|
+
if missing:
|
|
2953
|
+
result.update(
|
|
2954
|
+
{
|
|
2955
|
+
"error_type": "config_error",
|
|
2956
|
+
"error": "缺少 OpenAI-compatible 配置: " + ", ".join(missing),
|
|
2957
|
+
"summary": "OpenAI-compatible 配置不完整。",
|
|
2958
|
+
"recommendation": "请先运行 `smart-search setup`,或用 `smart-search config set` 填好缺失项。",
|
|
2959
|
+
"missing": missing,
|
|
2960
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2961
|
+
}
|
|
2962
|
+
)
|
|
2963
|
+
return result
|
|
2964
|
+
|
|
2965
|
+
try:
|
|
2966
|
+
quick = await _test_primary_chat_completion(api_url, api_key, model)
|
|
2967
|
+
except httpx.TimeoutException as e:
|
|
2968
|
+
quick = {"status": "timeout", "message": f"轻量 chat 请求超时: {e}"}
|
|
2969
|
+
except httpx.RequestError as e:
|
|
2970
|
+
quick = {"status": "error", "message": f"轻量 chat 网络错误: {e}"}
|
|
2971
|
+
except Exception as e:
|
|
2972
|
+
quick = {"status": "error", "message": f"轻量 chat 运行错误: {e}"}
|
|
2973
|
+
quick_check = {
|
|
2974
|
+
"name": "轻量 chat 请求",
|
|
2975
|
+
"status": quick.get("status", "error"),
|
|
2976
|
+
"message": quick.get("message", ""),
|
|
2977
|
+
"response_time_ms": quick.get("response_time_ms"),
|
|
2978
|
+
"http_status": quick.get("http_status"),
|
|
2979
|
+
"content_type": quick.get("content_type", ""),
|
|
2980
|
+
"has_content": bool(quick.get("has_content", quick.get("status") == "ok")),
|
|
2981
|
+
}
|
|
2982
|
+
result["checks"].append(quick_check)
|
|
2983
|
+
no_stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=False, timeout_seconds=timeout_seconds)
|
|
2984
|
+
result["checks"].append(no_stream)
|
|
2985
|
+
stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=True, timeout_seconds=timeout_seconds)
|
|
2986
|
+
result["checks"].append(stream)
|
|
2987
|
+
|
|
2988
|
+
ok, summary, recommendation = _openai_compatible_diagnosis(quick_check, no_stream, stream)
|
|
2989
|
+
result.update(
|
|
2990
|
+
{
|
|
2991
|
+
"ok": ok,
|
|
2992
|
+
"error_type": "" if ok else "network_error",
|
|
2993
|
+
"error": "" if ok else summary,
|
|
2994
|
+
"summary": summary,
|
|
2995
|
+
"recommendation": recommendation,
|
|
2996
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2997
|
+
}
|
|
2998
|
+
)
|
|
2999
|
+
return result
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
async def _test_primary_connection(api_url: str, api_key: str, model: str) -> dict[str, Any]:
|
|
3003
|
+
chat_test = await _test_primary_chat_completion(api_url, api_key, model)
|
|
3004
|
+
|
|
3005
|
+
models_url = f"{api_url.rstrip('/')}/models"
|
|
3006
|
+
start = time.time()
|
|
3007
|
+
try:
|
|
3008
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
3009
|
+
response = await client.get(
|
|
3010
|
+
models_url,
|
|
3011
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
3012
|
+
)
|
|
3013
|
+
response_time = _elapsed_ms(start)
|
|
3014
|
+
if response.status_code != 200:
|
|
3015
|
+
models_test = {"status": "warning", "message": f"HTTP {response.status_code}: {response.text[:100]}", "response_time_ms": response_time}
|
|
3016
|
+
else:
|
|
3017
|
+
models_test = {"status": "ok", "message": f"成功获取模型列表 (HTTP {response.status_code})", "response_time_ms": response_time}
|
|
3018
|
+
try:
|
|
3019
|
+
models_data = response.json()
|
|
3020
|
+
model_names = [m["id"] for m in models_data.get("data", []) if isinstance(m, dict) and "id" in m]
|
|
3021
|
+
models_test["message"] += f",共 {len(model_names)} 个模型"
|
|
3022
|
+
if model_names:
|
|
3023
|
+
models_test["available_models"] = model_names
|
|
3024
|
+
except Exception:
|
|
3025
|
+
pass
|
|
3026
|
+
except httpx.HTTPError as e:
|
|
3027
|
+
models_test = {"status": "warning", "message": f"模型列表接口请求失败: {e}", "response_time_ms": _elapsed_ms(start)}
|
|
3028
|
+
|
|
3029
|
+
if chat_test.get("status") != "ok":
|
|
3030
|
+
models_state = "可用" if models_test.get("status") == "ok" else "不可用"
|
|
3031
|
+
return {
|
|
3032
|
+
"status": "warning",
|
|
3033
|
+
"message": f"聊天接口不可用: {chat_test.get('message', '')};模型列表接口{models_state}: {models_test['message']}",
|
|
3034
|
+
"response_time_ms": chat_test.get("response_time_ms", models_test.get("response_time_ms")),
|
|
3035
|
+
"models_endpoint_test": models_test,
|
|
3036
|
+
"chat_completion_test": chat_test,
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
if models_test.get("status") != "ok":
|
|
3040
|
+
return {
|
|
3041
|
+
"status": "ok",
|
|
3042
|
+
"message": f"{chat_test['message']};模型列表接口不可用: {models_test['message']}",
|
|
3043
|
+
"response_time_ms": chat_test.get("response_time_ms"),
|
|
3044
|
+
"models_endpoint_test": models_test,
|
|
3045
|
+
"chat_completion_test": chat_test,
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
result: dict[str, Any] = {
|
|
3049
|
+
"status": "ok",
|
|
3050
|
+
"message": f"{chat_test['message']};{models_test['message']}",
|
|
3051
|
+
"response_time_ms": chat_test.get("response_time_ms"),
|
|
3052
|
+
"models_endpoint_test": models_test,
|
|
3053
|
+
"chat_completion_test": chat_test,
|
|
3054
|
+
}
|
|
3055
|
+
if "available_models" in models_test:
|
|
3056
|
+
result["available_models"] = models_test["available_models"]
|
|
3057
|
+
return result
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
async def _test_primary_responses(api_url: str, api_key: str, model: str) -> dict[str, Any]:
|
|
3061
|
+
responses_url = f"{api_url.rstrip('/')}/responses"
|
|
3062
|
+
start = time.time()
|
|
3063
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
3064
|
+
response = await client.post(
|
|
3065
|
+
responses_url,
|
|
3066
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
3067
|
+
json={
|
|
3068
|
+
"model": model,
|
|
3069
|
+
"input": [{"role": "user", "content": "Reply with exactly: ok"}],
|
|
3070
|
+
"stream": False,
|
|
3071
|
+
},
|
|
3072
|
+
)
|
|
3073
|
+
response_time = _elapsed_ms(start)
|
|
3074
|
+
if response.status_code != 200:
|
|
3075
|
+
return {"status": "warning", "message": f"HTTP {response.status_code}: {response.text[:100]}", "response_time_ms": response_time}
|
|
3076
|
+
return {"status": "ok", "message": f"xAI Responses API 可用 (HTTP {response.status_code})", "response_time_ms": response_time}
|
|
3077
|
+
|
|
3078
|
+
|
|
3079
|
+
async def _test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
|
|
3080
|
+
if provider_config["mode"] == "xai-responses":
|
|
3081
|
+
return await _test_primary_responses(provider_config["api_url"], provider_config["api_key"], provider_config["model"])
|
|
3082
|
+
return await _test_primary_connection(provider_config["api_url"], provider_config["api_key"], provider_config["model"])
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
async def _safe_test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
|
|
3086
|
+
try:
|
|
3087
|
+
return await _test_main_provider_connection(provider_config)
|
|
3088
|
+
except httpx.TimeoutException:
|
|
3089
|
+
return {"status": "timeout", "message": f"{provider_config['provider']} 请求超时,请检查网络连接或 API URL"}
|
|
3090
|
+
except httpx.RequestError as e:
|
|
3091
|
+
return {"status": "error", "message": f"{provider_config['provider']} 网络错误: {str(e)}"}
|
|
3092
|
+
except Exception as e:
|
|
3093
|
+
return {"status": "error", "message": f"{provider_config['provider']} 未知错误: {str(e)}"}
|
|
3094
|
+
|
|
3095
|
+
|
|
3096
|
+
async def _test_exa_connection() -> dict[str, Any]:
|
|
3097
|
+
exa_key = config.exa_api_key
|
|
3098
|
+
if not exa_key:
|
|
3099
|
+
return {"status": "not_configured", "message": "EXA_API_KEY 未设置,Exa 搜索功能不可用"}
|
|
3100
|
+
start = time.time()
|
|
3101
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
3102
|
+
resp = await client.post(
|
|
3103
|
+
f"{config.exa_base_url.rstrip('/')}/search",
|
|
3104
|
+
headers={"x-api-key": exa_key, "content-type": "application/json"},
|
|
3105
|
+
json={"query": "test", "numResults": 1, "type": "keyword"},
|
|
3106
|
+
)
|
|
3107
|
+
response_time = _elapsed_ms(start)
|
|
3108
|
+
if resp.status_code == 200:
|
|
3109
|
+
return {"status": "ok", "message": "Exa API 可用 (HTTP 200)", "response_time_ms": response_time}
|
|
3110
|
+
return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
async def _test_tavily_connection() -> dict[str, Any]:
|
|
3114
|
+
tavily_key = config.tavily_api_key
|
|
3115
|
+
if not tavily_key:
|
|
3116
|
+
return {"status": "not_configured", "message": "TAVILY_API_KEY 未设置,Tavily 功能不可用"}
|
|
3117
|
+
start = time.time()
|
|
3118
|
+
timeout = httpx.Timeout(connect=6.0, read=config.tavily_timeout, write=10.0, pool=None)
|
|
3119
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
|
|
3120
|
+
resp = await client.post(
|
|
3121
|
+
f"{config.tavily_api_url.rstrip('/')}/search",
|
|
3122
|
+
headers={"Authorization": f"Bearer {tavily_key}", "Content-Type": "application/json"},
|
|
3123
|
+
json={"query": "test", "max_results": 1, "search_depth": "basic"},
|
|
3124
|
+
)
|
|
3125
|
+
response_time = _elapsed_ms(start)
|
|
3126
|
+
if resp.status_code == 200:
|
|
3127
|
+
return {"status": "ok", "message": "Tavily API 可用 (HTTP 200)", "response_time_ms": response_time}
|
|
3128
|
+
return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
|
|
3129
|
+
|
|
3130
|
+
|
|
3131
|
+
async def _test_jina_connection() -> dict[str, Any]:
|
|
3132
|
+
if config.jina_respond_with and not config.jina_api_key:
|
|
3133
|
+
return {"status": "config_error", "message": "JINA_RESPOND_WITH requires JINA_API_KEY"}
|
|
3134
|
+
if not config.jina_api_key:
|
|
3135
|
+
return {"status": "not_configured", "message": "JINA_API_KEY 未设置,Jina 不满足 standard web_fetch;匿名 Reader 只能作为显式实验使用"}
|
|
3136
|
+
start = time.time()
|
|
3137
|
+
data = await jina_fetch("https://example.com")
|
|
3138
|
+
response_time = _elapsed_ms(start)
|
|
3139
|
+
if data.get("ok"):
|
|
3140
|
+
return {"status": "ok", "message": "Jina Reader 可用", "response_time_ms": response_time}
|
|
3141
|
+
error_type = data.get("error_type", "")
|
|
3142
|
+
status = error_type if error_type in {"auth_error", "config_error", "parameter_error", "rate_limited", "timeout"} else "warning"
|
|
3143
|
+
return {"status": status, "message": data.get("error", "Jina Reader 不可用"), "response_time_ms": response_time}
|
|
3144
|
+
|
|
3145
|
+
|
|
3146
|
+
async def _test_zhipu_connection() -> dict[str, Any]:
|
|
3147
|
+
if not config.zhipu_api_key:
|
|
3148
|
+
return {"status": "not_configured", "message": "ZHIPU_API_KEY 未设置,智谱搜索功能不可用"}
|
|
3149
|
+
result = await zhipu_search("test", count=1)
|
|
3150
|
+
if result.get("ok"):
|
|
3151
|
+
return {"status": "ok", "message": "智谱 Web Search 可用", "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3152
|
+
return {"status": "warning", "message": result.get("error", "智谱 Web Search 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3153
|
+
|
|
3154
|
+
|
|
3155
|
+
async def _test_zhipu_mcp_connection() -> dict[str, Any]:
|
|
3156
|
+
if not config.zhipu_mcp_api_key:
|
|
3157
|
+
return {"status": "not_configured", "message": "ZHIPU_MCP_API_KEY 未设置,智谱 Coding Plan MCP 功能不可用"}
|
|
3158
|
+
result = await zhipu_mcp_search("test", count=1)
|
|
3159
|
+
if result.get("ok"):
|
|
3160
|
+
return {"status": "ok", "message": "智谱 Coding Plan MCP 可用", "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3161
|
+
error_type = result.get("error_type", "")
|
|
3162
|
+
status = error_type if error_type in {"auth_error", "config_error", "provider_error", "rate_limited", "timeout"} else "warning"
|
|
3163
|
+
return {"status": status, "message": result.get("error", "智谱 Coding Plan MCP 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3164
|
+
|
|
3165
|
+
|
|
3166
|
+
async def _test_context7_connection() -> dict[str, Any]:
|
|
3167
|
+
if not config.context7_api_key:
|
|
3168
|
+
return {"status": "not_configured", "message": "CONTEXT7_API_KEY 未设置,Context7 功能不可用"}
|
|
3169
|
+
result = await context7_library("react", "hooks")
|
|
3170
|
+
if result.get("ok"):
|
|
3171
|
+
return {"status": "ok", "message": "Context7 API 可用", "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3172
|
+
return {"status": "warning", "message": result.get("error", "Context7 API 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
|
|
3173
|
+
|
|
3174
|
+
|
|
3175
|
+
async def doctor() -> dict[str, Any]:
|
|
3176
|
+
info = config.get_config_info()
|
|
3177
|
+
|
|
3178
|
+
main_provider_configs: list[dict[str, Any]] = []
|
|
3179
|
+
try:
|
|
3180
|
+
main_provider_configs = _main_search_provider_configs()
|
|
3181
|
+
info["main_search_connection_tests"] = {}
|
|
3182
|
+
for provider_config in main_provider_configs:
|
|
3183
|
+
info["main_search_connection_tests"][provider_config["provider"]] = await _safe_test_main_provider_connection(provider_config)
|
|
3184
|
+
if main_provider_configs:
|
|
3185
|
+
first_provider = main_provider_configs[0]
|
|
3186
|
+
info["primary_api_mode"] = first_provider["mode"]
|
|
3187
|
+
info["primary_connection_test"] = info["main_search_connection_tests"][first_provider["provider"]]
|
|
3188
|
+
else:
|
|
3189
|
+
info["primary_connection_test"] = {"status": "config_error", "message": MINIMUM_PROFILE_ERROR}
|
|
3190
|
+
except ValueError as e:
|
|
3191
|
+
info["main_search_connection_tests"] = {}
|
|
3192
|
+
info["primary_connection_test"] = {"status": "config_error", "message": str(e)}
|
|
3193
|
+
except Exception as e:
|
|
3194
|
+
info["main_search_connection_tests"] = {}
|
|
3195
|
+
info["primary_connection_test"] = {"status": "error", "message": f"未知错误: {str(e)}"}
|
|
3196
|
+
|
|
3197
|
+
try:
|
|
3198
|
+
info["exa_connection_test"] = await _test_exa_connection()
|
|
3199
|
+
except httpx.TimeoutException:
|
|
3200
|
+
info["exa_connection_test"] = {"status": "timeout", "message": "Exa API 请求超时"}
|
|
3201
|
+
except Exception as e:
|
|
3202
|
+
info["exa_connection_test"] = {"status": "error", "message": str(e)}
|
|
3203
|
+
|
|
3204
|
+
try:
|
|
3205
|
+
info["tavily_connection_test"] = await _test_tavily_connection()
|
|
3206
|
+
except httpx.TimeoutException:
|
|
3207
|
+
info["tavily_connection_test"] = {"status": "timeout", "message": "Tavily API 请求超时"}
|
|
3208
|
+
except Exception as e:
|
|
3209
|
+
info["tavily_connection_test"] = {"status": "error", "message": str(e)}
|
|
3210
|
+
|
|
3211
|
+
try:
|
|
3212
|
+
info["jina_connection_test"] = await _test_jina_connection()
|
|
3213
|
+
except httpx.TimeoutException:
|
|
3214
|
+
info["jina_connection_test"] = {"status": "timeout", "message": "Jina Reader 请求超时"}
|
|
3215
|
+
except Exception as e:
|
|
3216
|
+
info["jina_connection_test"] = {"status": "error", "message": str(e)}
|
|
3217
|
+
|
|
3218
|
+
if config.firecrawl_api_key:
|
|
3219
|
+
info["firecrawl_connection_test"] = {"status": "configured", "message": "FIRECRAWL_API_KEY 已设置"}
|
|
3220
|
+
else:
|
|
3221
|
+
info["firecrawl_connection_test"] = {"status": "not_configured", "message": "FIRECRAWL_API_KEY 未设置,Firecrawl 功能不可用"}
|
|
3222
|
+
|
|
3223
|
+
try:
|
|
3224
|
+
info["zhipu_connection_test"] = await _test_zhipu_connection()
|
|
3225
|
+
except httpx.TimeoutException:
|
|
3226
|
+
info["zhipu_connection_test"] = {"status": "timeout", "message": "智谱 API 请求超时"}
|
|
3227
|
+
except Exception as e:
|
|
3228
|
+
info["zhipu_connection_test"] = {"status": "error", "message": str(e)}
|
|
3229
|
+
|
|
3230
|
+
try:
|
|
3231
|
+
info["zhipu_mcp_connection_test"] = await _test_zhipu_mcp_connection()
|
|
3232
|
+
except httpx.TimeoutException:
|
|
3233
|
+
info["zhipu_mcp_connection_test"] = {"status": "timeout", "message": "智谱 Coding Plan MCP 请求超时"}
|
|
3234
|
+
except Exception as e:
|
|
3235
|
+
info["zhipu_mcp_connection_test"] = {"status": "error", "message": str(e)}
|
|
3236
|
+
|
|
3237
|
+
try:
|
|
3238
|
+
info["context7_connection_test"] = await _test_context7_connection()
|
|
3239
|
+
except httpx.TimeoutException:
|
|
3240
|
+
info["context7_connection_test"] = {"status": "timeout", "message": "Context7 API 请求超时"}
|
|
3241
|
+
except Exception as e:
|
|
3242
|
+
info["context7_connection_test"] = {"status": "error", "message": str(e)}
|
|
3243
|
+
|
|
3244
|
+
minimum = validate_minimum_profile()
|
|
3245
|
+
info["capability_status"] = minimum.get("capability_status", get_capability_status())
|
|
3246
|
+
info["operation_status"] = {
|
|
3247
|
+
operation: {
|
|
3248
|
+
"configured": candidates,
|
|
3249
|
+
"ok": bool(candidates),
|
|
3250
|
+
"single_provider": len(candidates) == 1,
|
|
3251
|
+
"fallback_available": len(candidates) > 1,
|
|
3252
|
+
"features": operation_profiles()[operation]["features"],
|
|
3253
|
+
}
|
|
3254
|
+
for operation in OPERATION_PROFILES
|
|
3255
|
+
for candidates, _missing in [operation_candidates(operation)]
|
|
3256
|
+
}
|
|
3257
|
+
info["minimum_profile_ok"] = minimum.get("ok", False)
|
|
3258
|
+
info["minimum_profile_missing"] = minimum.get("missing", [])
|
|
3259
|
+
info["intent_router_status"] = intent_router_status()
|
|
3260
|
+
main_connection_tests = info.get("main_search_connection_tests") or {}
|
|
3261
|
+
main_search_statuses = [item.get("status") for item in main_connection_tests.values() if isinstance(item, dict)]
|
|
3262
|
+
primary_test = info.get("primary_connection_test", {})
|
|
3263
|
+
primary_status = primary_test.get("status")
|
|
3264
|
+
main_search_ok = any(status == "ok" for status in main_search_statuses) if main_connection_tests else primary_status == "ok"
|
|
3265
|
+
info["ok"] = main_search_ok and minimum.get("ok", False)
|
|
3266
|
+
if info["ok"]:
|
|
3267
|
+
info["error_type"] = ""
|
|
3268
|
+
info["error"] = ""
|
|
3269
|
+
elif info.get("config_parameter_errors"):
|
|
3270
|
+
info["error"] = "; ".join(info["config_parameter_errors"])
|
|
3271
|
+
info["error_type"] = "parameter_error"
|
|
3272
|
+
elif not minimum.get("ok", False):
|
|
3273
|
+
info["error"] = minimum.get("error", MINIMUM_PROFILE_ERROR)
|
|
3274
|
+
info["error_type"] = minimum.get("error_type", "config_error")
|
|
3275
|
+
else:
|
|
3276
|
+
info["error"] = primary_test.get("message", "Primary connection check failed")
|
|
3277
|
+
if primary_status == "config_error":
|
|
3278
|
+
info["error_type"] = "config_error"
|
|
3279
|
+
elif primary_status in {"timeout", "error", "warning"}:
|
|
3280
|
+
info["error_type"] = "network_error"
|
|
3281
|
+
else:
|
|
3282
|
+
info["error_type"] = "runtime_error"
|
|
3283
|
+
return info
|
|
3284
|
+
|
|
3285
|
+
|
|
3286
|
+
def current_model() -> dict[str, Any]:
|
|
3287
|
+
return {
|
|
3288
|
+
"ok": True,
|
|
3289
|
+
"xai_model": config.xai_model,
|
|
3290
|
+
"openai_compatible_model": config.openai_compatible_model,
|
|
3291
|
+
"openai_compatible_fallback_models": config.openai_compatible_fallback_models,
|
|
3292
|
+
"config_file": str(config.config_file),
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
|
|
3296
|
+
def set_model(model: str) -> dict[str, Any]:
|
|
3297
|
+
return {
|
|
3298
|
+
"ok": False,
|
|
3299
|
+
"error_type": "parameter_error",
|
|
3300
|
+
"error": (
|
|
3301
|
+
"The legacy default model command was removed. Use `smart-search config set XAI_MODEL <model>` "
|
|
3302
|
+
"or `smart-search config set OPENAI_COMPATIBLE_MODEL <model>`."
|
|
3303
|
+
),
|
|
3304
|
+
"config_file": str(config.config_file),
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
def config_path() -> dict[str, Any]:
|
|
3309
|
+
return config.config_path_info()
|
|
3310
|
+
|
|
3311
|
+
|
|
3312
|
+
def config_list(show_secrets: bool = False) -> dict[str, Any]:
|
|
3313
|
+
return {
|
|
3314
|
+
"ok": True,
|
|
3315
|
+
"config_file": str(config.config_file),
|
|
3316
|
+
"values": config.get_saved_config(masked=not show_secrets),
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
|
|
3320
|
+
def config_set(key: str, value: str) -> dict[str, Any]:
|
|
3321
|
+
try:
|
|
3322
|
+
config.set_config_value(key, value)
|
|
3323
|
+
except ValueError as e:
|
|
3324
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file)}
|
|
3325
|
+
saved = config.get_saved_config(masked=True)
|
|
3326
|
+
return {
|
|
3327
|
+
"ok": True,
|
|
3328
|
+
"config_file": str(config.config_file),
|
|
3329
|
+
"key": key.strip().upper(),
|
|
3330
|
+
"value": saved.get(key.strip().upper(), ""),
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
|
|
3334
|
+
def config_unset(key: str) -> dict[str, Any]:
|
|
3335
|
+
try:
|
|
3336
|
+
config.unset_config_value(key)
|
|
3337
|
+
except ValueError as e:
|
|
3338
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file), "key": key.strip().upper()}
|
|
3339
|
+
return {"ok": True, "config_file": str(config.config_file), "key": key.strip().upper()}
|
|
3340
|
+
|
|
3341
|
+
|
|
3342
|
+
async def smoke(mode: str = "mock") -> dict[str, Any]:
|
|
3343
|
+
start = time.time()
|
|
3344
|
+
mode = (mode or "mock").strip().lower()
|
|
3345
|
+
if mode not in {"mock", "live"}:
|
|
3346
|
+
return {"ok": False, "error_type": "parameter_error", "error": "mode must be mock or live"}
|
|
3347
|
+
if mode == "live":
|
|
3348
|
+
return await _smoke_live(start)
|
|
3349
|
+
cases = []
|
|
3350
|
+
for operation in OPERATION_PROFILES:
|
|
3351
|
+
candidates, _ = operation_candidates(operation)
|
|
3352
|
+
cases.append(_case(f"operation profile {operation}", True, {"configured": candidates}))
|
|
3353
|
+
cases.append(_case("public operation provider matrix", all(profile["providers"] for profile in OPERATION_PROFILES.values())))
|
|
3354
|
+
cases.append(_case("same-operation fallback contract", True))
|
|
3355
|
+
return {"ok": all(case["ok"] for case in cases), "mode": "mock", "cases": cases, "failed_cases": [], "elapsed_ms": _elapsed_ms(start)}
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
def _case(name: str, ok: bool, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
3359
|
+
return {"name": name, "ok": ok, **(details or {})}
|
|
3360
|
+
|
|
3361
|
+
|
|
3362
|
+
def _case_failed(case: dict[str, Any]) -> bool:
|
|
3363
|
+
return not case.get("ok") and case.get("severity", "critical") != "degraded"
|
|
3364
|
+
|
|
3365
|
+
|
|
3366
|
+
async def _smoke_live(start: float) -> dict[str, Any]:
|
|
3367
|
+
cases: list[dict[str, Any]] = []
|
|
3368
|
+
doctor_result = await doctor()
|
|
3369
|
+
capability_status = doctor_result.get("capability_status", {})
|
|
3370
|
+
cases.append(
|
|
3371
|
+
_case(
|
|
3372
|
+
"doctor minimum profile",
|
|
3373
|
+
bool(doctor_result.get("minimum_profile_ok")),
|
|
3374
|
+
{
|
|
3375
|
+
"error_type": doctor_result.get("error_type", ""),
|
|
3376
|
+
"error": doctor_result.get("error", ""),
|
|
3377
|
+
"capability_status": doctor_result.get("capability_status", {}),
|
|
3378
|
+
},
|
|
3379
|
+
)
|
|
3380
|
+
)
|
|
3381
|
+
|
|
3382
|
+
zhipu_status = doctor_result.get("zhipu_connection_test", {})
|
|
3383
|
+
if config.zhipu_api_key:
|
|
3384
|
+
zhipu_ok = zhipu_status.get("status") == "ok"
|
|
3385
|
+
web_fallback_available = len(capability_status.get("web_search", {}).get("configured", [])) > 1
|
|
3386
|
+
cases.append(
|
|
3387
|
+
_case(
|
|
3388
|
+
"zhipu search",
|
|
3389
|
+
zhipu_ok,
|
|
3390
|
+
{
|
|
3391
|
+
"status": zhipu_status.get("status", ""),
|
|
3392
|
+
"error": zhipu_status.get("message", ""),
|
|
3393
|
+
"severity": "" if zhipu_ok else ("degraded" if web_fallback_available else "critical"),
|
|
3394
|
+
"fallback_available": web_fallback_available,
|
|
3395
|
+
},
|
|
3396
|
+
)
|
|
3397
|
+
)
|
|
3398
|
+
else:
|
|
3399
|
+
cases.append(_case("zhipu search", True, {"skipped": "ZHIPU_API_KEY not configured"}))
|
|
3400
|
+
|
|
3401
|
+
context7_status = doctor_result.get("context7_connection_test", {})
|
|
3402
|
+
if config.context7_api_key:
|
|
3403
|
+
context7_ok = context7_status.get("status") == "ok"
|
|
3404
|
+
docs_fallback_available = len(capability_status.get("docs_search", {}).get("configured", [])) > 1
|
|
3405
|
+
cases.append(
|
|
3406
|
+
_case(
|
|
3407
|
+
"context7 library",
|
|
3408
|
+
context7_ok,
|
|
3409
|
+
{
|
|
3410
|
+
"status": context7_status.get("status", ""),
|
|
3411
|
+
"error": context7_status.get("message", ""),
|
|
3412
|
+
"severity": "" if context7_ok else ("degraded" if docs_fallback_available else "critical"),
|
|
3413
|
+
"fallback_available": docs_fallback_available,
|
|
3414
|
+
},
|
|
3415
|
+
)
|
|
3416
|
+
)
|
|
3417
|
+
else:
|
|
3418
|
+
cases.append(_case("context7 library", True, {"skipped": "CONTEXT7_API_KEY not configured"}))
|
|
3419
|
+
|
|
3420
|
+
if config.tavily_api_key or config.firecrawl_api_key:
|
|
3421
|
+
fetch_result = await fetch("https://example.com")
|
|
3422
|
+
cases.append(_case("web fetch fallback chain", bool(fetch_result.get("ok")), {"provider": fetch_result.get("provider", ""), "provider_attempts": fetch_result.get("provider_attempts", [])}))
|
|
3423
|
+
else:
|
|
3424
|
+
cases.append(_case("web fetch fallback chain", True, {"skipped": "no fetch providers configured"}))
|
|
3425
|
+
|
|
3426
|
+
failed = [c["name"] for c in cases if _case_failed(c)]
|
|
3427
|
+
degraded = [c["name"] for c in cases if not c.get("ok") and c.get("severity") == "degraded"]
|
|
3428
|
+
attempts: list[dict] = []
|
|
3429
|
+
for c in cases:
|
|
3430
|
+
attempts.extend(c.get("provider_attempts", []))
|
|
3431
|
+
return {
|
|
3432
|
+
"ok": not failed,
|
|
3433
|
+
"mode": "live",
|
|
3434
|
+
"failed_cases": failed,
|
|
3435
|
+
"degraded_cases": degraded,
|
|
3436
|
+
"cases": cases,
|
|
3437
|
+
"provider_attempts": attempts,
|
|
3438
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
|
|
3442
|
+
def write_output(path: str | Path, content: str) -> None:
|
|
3443
|
+
target = Path(path)
|
|
3444
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3445
|
+
target.write_text(content, encoding="utf-8")
|