@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/README.zh-CN.md +180 -0
  4. package/npm/bin/smart-search.js +68 -0
  5. package/npm/scripts/postinstall.js +87 -0
  6. package/npm/scripts/resolve-prerelease-version.js +108 -0
  7. package/npm/scripts/set-package-version.js +35 -0
  8. package/npm/scripts/sync-python-version.js +22 -0
  9. package/npm/scripts/test-wrapper-repair.js +137 -0
  10. package/npm/scripts/test.js +76 -0
  11. package/package.json +42 -0
  12. package/pyproject.toml +36 -0
  13. package/skills/smart-search-cli/SKILL.md +41 -0
  14. package/skills/smart-search-cli/agents/openai.yaml +3 -0
  15. package/skills/smart-search-cli/references/cli-contract.md +7 -0
  16. package/skills/smart-search-cli/references/cli-core.md +5 -0
  17. package/skills/smart-search-cli/references/command-patterns.md +12 -0
  18. package/skills/smart-search-cli/references/provider-routing.md +3 -0
  19. package/skills/smart-search-cli/references/regression-release.md +28 -0
  20. package/skills/smart-search-cli/references/setup-config.md +3 -0
  21. package/src/smart_search/__init__.py +1 -0
  22. package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
  23. package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
  24. package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
  25. package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
  26. package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
  27. package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
  28. package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
  29. package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
  30. package/src/smart_search/cli.py +3118 -0
  31. package/src/smart_search/config.py +809 -0
  32. package/src/smart_search/embedding_presets.py +40 -0
  33. package/src/smart_search/intent_router.py +757 -0
  34. package/src/smart_search/logger.py +43 -0
  35. package/src/smart_search/providers/__init__.py +20 -0
  36. package/src/smart_search/providers/base.py +41 -0
  37. package/src/smart_search/providers/context7.py +141 -0
  38. package/src/smart_search/providers/exa.py +206 -0
  39. package/src/smart_search/providers/jina.py +136 -0
  40. package/src/smart_search/providers/openai_compatible.py +541 -0
  41. package/src/smart_search/providers/xai_responses.py +117 -0
  42. package/src/smart_search/providers/zhipu.py +143 -0
  43. package/src/smart_search/providers/zhipu_mcp.py +230 -0
  44. package/src/smart_search/service.py +3445 -0
  45. package/src/smart_search/skill_installer.py +342 -0
  46. package/src/smart_search/sources.py +429 -0
  47. 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")