@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,809 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ class Config:
7
+ _instance = None
8
+ _SETUP_COMMAND = (
9
+ "Run `smart-search setup`, or configure XAI_API_KEY and/or "
10
+ "OPENAI_COMPATIBLE_API_URL plus OPENAI_COMPATIBLE_API_KEY, then run "
11
+ "`smart-search doctor --format json`."
12
+ )
13
+ _DEFAULT_MODEL = "grok-4-fast"
14
+ _DEFAULT_XAI_TOOLS = "web_search,x_search"
15
+ _DEFAULT_VALIDATION_LEVEL = "balanced"
16
+ _DEFAULT_FALLBACK_MODE = "auto"
17
+ _DEFAULT_MINIMUM_PROFILE = "standard"
18
+ _DEFAULT_INTENT_ROUTER_MODE = "hybrid"
19
+ _DEFAULT_INTENT_ROUTER_TIMEOUT_SECONDS = "8"
20
+ _DEFAULT_INTENT_EMBEDDING_THRESHOLD = "0.74"
21
+ _DEFAULT_INTENT_EMBEDDING_MARGIN = "0.05"
22
+ _ALLOWED_XAI_TOOLS = {"web_search", "x_search"}
23
+ _ALLOWED_VALIDATION_LEVELS = {"fast", "balanced", "strict"}
24
+ _ALLOWED_FALLBACK_MODES = {"auto", "off"}
25
+ _ALLOWED_MINIMUM_PROFILES = {"standard", "off"}
26
+ _ALLOWED_INTENT_ROUTER_MODES = {"hybrid", "rules", "off"}
27
+ _CONFIG_KEYS = {
28
+ "XAI_API_URL",
29
+ "XAI_API_KEY",
30
+ "XAI_MODEL",
31
+ "XAI_TOOLS",
32
+ "OPENAI_COMPATIBLE_API_URL",
33
+ "OPENAI_COMPATIBLE_API_KEY",
34
+ "OPENAI_COMPATIBLE_MODEL",
35
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS",
36
+ "OPENAI_COMPATIBLE_STREAM",
37
+ "SMART_SEARCH_VALIDATION_LEVEL",
38
+ "SMART_SEARCH_FALLBACK_MODE",
39
+ "SMART_SEARCH_MINIMUM_PROFILE",
40
+ "SMART_SEARCH_OPERATION_CONFIG",
41
+ "SMART_SEARCH_INTENT_ROUTER",
42
+ "INTENT_EMBEDDING_API_URL",
43
+ "INTENT_EMBEDDING_API_KEY",
44
+ "INTENT_EMBEDDING_MODEL",
45
+ "INTENT_EMBEDDING_THRESHOLD",
46
+ "INTENT_EMBEDDING_MARGIN",
47
+ "INTENT_CLASSIFIER_API_URL",
48
+ "INTENT_CLASSIFIER_API_KEY",
49
+ "INTENT_CLASSIFIER_MODEL",
50
+ "INTENT_ROUTER_TIMEOUT_SECONDS",
51
+ "EXA_API_KEY",
52
+ "EXA_BASE_URL",
53
+ "EXA_TIMEOUT_SECONDS",
54
+ "CONTEXT7_API_KEY",
55
+ "CONTEXT7_BASE_URL",
56
+ "CONTEXT7_TIMEOUT_SECONDS",
57
+ "ZHIPU_API_KEY",
58
+ "ZHIPU_API_URL",
59
+ "ZHIPU_SEARCH_ENGINE",
60
+ "ZHIPU_TIMEOUT_SECONDS",
61
+ "ZHIPU_MCP_API_KEY",
62
+ "ZHIPU_MCP_SEARCH_API_URL",
63
+ "ZHIPU_MCP_READER_API_URL",
64
+ "ZHIPU_MCP_ZREAD_API_URL",
65
+ "ZHIPU_MCP_TIMEOUT_SECONDS",
66
+ "JINA_API_KEY",
67
+ "JINA_READER_API_URL",
68
+ "JINA_RESPOND_WITH",
69
+ "JINA_TIMEOUT_SECONDS",
70
+ "TAVILY_API_KEY",
71
+ "TAVILY_API_URL",
72
+ "TAVILY_ENABLED",
73
+ "TAVILY_TIMEOUT_SECONDS",
74
+ "FIRECRAWL_API_KEY",
75
+ "FIRECRAWL_API_URL",
76
+ "SMART_SEARCH_DEBUG",
77
+ "SMART_SEARCH_LOG_LEVEL",
78
+ "SMART_SEARCH_LOG_DIR",
79
+ "SMART_SEARCH_RETRY_MAX_ATTEMPTS",
80
+ "SMART_SEARCH_RETRY_MULTIPLIER",
81
+ "SMART_SEARCH_RETRY_MAX_WAIT",
82
+ "SMART_SEARCH_OUTPUT_CLEANUP",
83
+ "SMART_SEARCH_LOG_TO_FILE",
84
+ "SSL_VERIFY",
85
+ }
86
+ _LEGACY_CONFIG_KEYS: dict[str, str] = {}
87
+
88
+ def __new__(cls):
89
+ if cls._instance is None:
90
+ cls._instance = super().__new__(cls)
91
+ cls._instance._config_file = None
92
+ cls._instance._config_dir_source = None
93
+ cls._instance._cached_model = None
94
+ return cls._instance
95
+
96
+ @staticmethod
97
+ def _default_config_dir() -> Path:
98
+ if sys.platform.startswith("win"):
99
+ local_appdata = os.getenv("LOCALAPPDATA")
100
+ if local_appdata:
101
+ return Path(local_appdata).expanduser() / "smart-search"
102
+ return Path.home() / ".config" / "smart-search"
103
+
104
+ @staticmethod
105
+ def _legacy_windows_config_dir() -> Path:
106
+ return Path.home() / ".config" / "smart-search"
107
+
108
+ @staticmethod
109
+ def _config_dir_override_value() -> str:
110
+ return os.getenv("SMART_SEARCH_CONFIG_DIR") or ""
111
+
112
+ @staticmethod
113
+ def _same_config_dir(left: Path, right: Path) -> bool:
114
+ left_text = os.path.abspath(os.path.expanduser(str(left)))
115
+ right_text = os.path.abspath(os.path.expanduser(str(right)))
116
+ if sys.platform.startswith("win"):
117
+ left_text = left_text.replace("/", "\\").rstrip("\\").lower()
118
+ right_text = right_text.replace("/", "\\").rstrip("\\").lower()
119
+ else:
120
+ left_text = left_text.rstrip("/")
121
+ right_text = right_text.rstrip("/")
122
+ return left_text == right_text
123
+
124
+ @classmethod
125
+ def _config_dir_override_matches_default(cls) -> bool:
126
+ env_dir = cls._config_dir_override_value()
127
+ if not env_dir:
128
+ return False
129
+ return cls._same_config_dir(Path(env_dir).expanduser(), cls._default_config_dir())
130
+
131
+ @staticmethod
132
+ def _resolve_config_dir() -> tuple[Path, str]:
133
+ env_dir = os.getenv("SMART_SEARCH_CONFIG_DIR")
134
+ if env_dir:
135
+ return Path(env_dir).expanduser(), "environment"
136
+ default_dir = Config._default_config_dir()
137
+ if sys.platform.startswith("win"):
138
+ legacy_dir = Config._legacy_windows_config_dir()
139
+ if legacy_dir != default_dir and not (default_dir / "config.json").exists() and (legacy_dir / "config.json").exists():
140
+ return legacy_dir, "legacy_windows_home"
141
+ return default_dir, "default"
142
+
143
+ @staticmethod
144
+ def _safe_mkdir(p: Path) -> bool:
145
+ try:
146
+ p.mkdir(parents=True, exist_ok=True)
147
+ return True
148
+ except (PermissionError, OSError):
149
+ return False
150
+
151
+ @property
152
+ def config_file(self) -> Path:
153
+ if self._config_file is None:
154
+ config_dir, config_dir_source = self._resolve_config_dir()
155
+ ok = self._safe_mkdir(config_dir)
156
+ if config_dir_source == "default" and not ok:
157
+ cwd_dir = Path.cwd() / ".smart-search"
158
+ if self._safe_mkdir(cwd_dir):
159
+ config_dir = cwd_dir
160
+ config_dir_source = "cwd_fallback"
161
+ self._config_file = config_dir / "config.json"
162
+ self._config_dir_source = config_dir_source
163
+ return self._config_file
164
+
165
+ @property
166
+ def config_dir_source(self) -> str:
167
+ if self._config_file is None:
168
+ _ = self.config_file
169
+ return self._config_dir_source or "override"
170
+
171
+ def _load_config_file(self) -> dict:
172
+ try:
173
+ with open(self.config_file, 'r', encoding='utf-8') as f:
174
+ data = json.load(f)
175
+ return data if isinstance(data, dict) else {}
176
+ except (FileNotFoundError, PermissionError, OSError, json.JSONDecodeError):
177
+ return {}
178
+
179
+ def _save_config_file(self, config_data: dict) -> None:
180
+ try:
181
+ with open(self.config_file, 'w', encoding='utf-8') as f:
182
+ json.dump(config_data, f, ensure_ascii=False, indent=2)
183
+ except (IOError, PermissionError, OSError) as e:
184
+ hint = " (sandbox/CI 下可设 SMART_SEARCH_CONFIG_DIR 指向可写目录)" if isinstance(e, PermissionError) else ""
185
+ raise ValueError(f"无法保存配置文件: {str(e)}{hint}")
186
+
187
+ def _get_config_value(self, key: str, default: str | None = None) -> str | None:
188
+ env_value = os.getenv(key)
189
+ if env_value is not None:
190
+ return env_value
191
+
192
+ data = self._load_config_file()
193
+ value = data.get(key)
194
+ if value is None:
195
+ legacy_key = next((old for old, new in self._LEGACY_CONFIG_KEYS.items() if new == key), None)
196
+ if legacy_key:
197
+ value = data.get(legacy_key)
198
+ if value is None:
199
+ return default
200
+ return str(value)
201
+
202
+ def get_saved_config(self, masked: bool = True) -> dict:
203
+ data = self._load_config_file()
204
+ normalized: dict[str, str] = {}
205
+ for old_key, new_key in self._LEGACY_CONFIG_KEYS.items():
206
+ if old_key in data and new_key not in data:
207
+ normalized[new_key] = str(data[old_key])
208
+ for key, value in data.items():
209
+ if key in self._CONFIG_KEYS and value is not None:
210
+ normalized[key] = str(value)
211
+ if not masked:
212
+ return normalized
213
+ return {key: self._mask_if_secret(key, value) for key, value in normalized.items()}
214
+
215
+ def get_config_source(self, key: str) -> str:
216
+ if os.getenv(key) is not None:
217
+ return "environment"
218
+ data = self._load_config_file()
219
+ if key in data:
220
+ return "config_file"
221
+ legacy_key = next((old for old, new in self._LEGACY_CONFIG_KEYS.items() if new == key), None)
222
+ if legacy_key and legacy_key in data:
223
+ return "config_file"
224
+ return "default"
225
+
226
+ def get_config_sources(self) -> dict[str, str]:
227
+ return {key: self.get_config_source(key) for key in sorted(self._CONFIG_KEYS)}
228
+
229
+ def set_config_value(self, key: str, value: str) -> None:
230
+ key = key.strip().upper()
231
+ if key not in self._CONFIG_KEYS:
232
+ raise ValueError(f"Unsupported config key: {key}")
233
+ config_data = self._load_config_file()
234
+ config_data[key] = value
235
+ self._save_config_file(config_data)
236
+ if key in {
237
+ "XAI_API_URL",
238
+ "XAI_API_KEY",
239
+ "XAI_MODEL",
240
+ "XAI_TOOLS",
241
+ "OPENAI_COMPATIBLE_API_URL",
242
+ "OPENAI_COMPATIBLE_API_KEY",
243
+ "OPENAI_COMPATIBLE_MODEL",
244
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS",
245
+ "OPENAI_COMPATIBLE_STREAM",
246
+ "SMART_SEARCH_VALIDATION_LEVEL",
247
+ "SMART_SEARCH_FALLBACK_MODE",
248
+ "SMART_SEARCH_MINIMUM_PROFILE",
249
+ "SMART_SEARCH_INTENT_ROUTER",
250
+ }:
251
+ self._cached_model = None
252
+
253
+ def unset_config_value(self, key: str) -> None:
254
+ key = key.strip().upper()
255
+ if key not in self._CONFIG_KEYS:
256
+ raise ValueError(f"Unsupported config key: {key}")
257
+ config_data = self._load_config_file()
258
+ config_data.pop(key, None)
259
+ for old_key, new_key in self._LEGACY_CONFIG_KEYS.items():
260
+ if new_key == key:
261
+ config_data.pop(old_key, None)
262
+ self._save_config_file(config_data)
263
+ if key in {
264
+ "XAI_API_URL",
265
+ "XAI_API_KEY",
266
+ "XAI_MODEL",
267
+ "XAI_TOOLS",
268
+ "OPENAI_COMPATIBLE_API_URL",
269
+ "OPENAI_COMPATIBLE_API_KEY",
270
+ "OPENAI_COMPATIBLE_MODEL",
271
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS",
272
+ "OPENAI_COMPATIBLE_STREAM",
273
+ "SMART_SEARCH_VALIDATION_LEVEL",
274
+ "SMART_SEARCH_FALLBACK_MODE",
275
+ "SMART_SEARCH_MINIMUM_PROFILE",
276
+ "SMART_SEARCH_INTENT_ROUTER",
277
+ }:
278
+ self._cached_model = None
279
+
280
+ def config_path_info(self) -> dict:
281
+ return {
282
+ "ok": True,
283
+ "config_file": str(self.config_file),
284
+ "config_dir": str(self.config_file.parent),
285
+ "config_dir_source": self.config_dir_source,
286
+ "default_config_file": str(self._default_config_dir() / "config.json"),
287
+ "legacy_windows_config_file": str(self._legacy_windows_config_dir() / "config.json") if sys.platform.startswith("win") else "",
288
+ "legacy_windows_config_exists": (self._legacy_windows_config_dir() / "config.json").exists() if sys.platform.startswith("win") else False,
289
+ "config_dir_override_value": self._config_dir_override_value(),
290
+ "config_dir_override_matches_default": self._config_dir_override_matches_default(),
291
+ "exists": self.config_file.exists(),
292
+ }
293
+
294
+ @property
295
+ def debug_enabled(self) -> bool:
296
+ return (self._get_config_value("SMART_SEARCH_DEBUG", "false") or "false").lower() in ("true", "1", "yes")
297
+
298
+ @property
299
+ def retry_max_attempts(self) -> int:
300
+ return int(self._get_config_value("SMART_SEARCH_RETRY_MAX_ATTEMPTS", "3") or "3")
301
+
302
+ @property
303
+ def retry_multiplier(self) -> float:
304
+ return float(self._get_config_value("SMART_SEARCH_RETRY_MULTIPLIER", "1") or "1")
305
+
306
+ @property
307
+ def retry_max_wait(self) -> int:
308
+ return int(self._get_config_value("SMART_SEARCH_RETRY_MAX_WAIT", "10") or "10")
309
+
310
+ @property
311
+ def xai_api_url(self) -> str:
312
+ return self._get_config_value("XAI_API_URL", "https://api.x.ai/v1") or "https://api.x.ai/v1"
313
+
314
+ @property
315
+ def xai_api_key(self) -> str | None:
316
+ return self._get_config_value("XAI_API_KEY")
317
+
318
+ @property
319
+ def xai_model(self) -> str:
320
+ return self._get_config_value("XAI_MODEL") or self._base_model_value()
321
+
322
+ @property
323
+ def xai_tools_raw(self) -> str:
324
+ return self._get_config_value("XAI_TOOLS", self._DEFAULT_XAI_TOOLS) or self._DEFAULT_XAI_TOOLS
325
+
326
+ @property
327
+ def openai_compatible_api_url(self) -> str | None:
328
+ return self._get_config_value("OPENAI_COMPATIBLE_API_URL")
329
+
330
+ @property
331
+ def openai_compatible_api_key(self) -> str | None:
332
+ return self._get_config_value("OPENAI_COMPATIBLE_API_KEY")
333
+
334
+ @property
335
+ def openai_compatible_model(self) -> str:
336
+ model = self._get_config_value("OPENAI_COMPATIBLE_MODEL") or self._base_model_value()
337
+ return self.apply_model_suffix_for_url(model, self.openai_compatible_api_url or "")
338
+
339
+ @property
340
+ def openai_compatible_fallback_models(self) -> list[str]:
341
+ raw = self._get_config_value("OPENAI_COMPATIBLE_FALLBACK_MODELS", "") or ""
342
+ models: list[str] = []
343
+ seen: set[str] = set()
344
+ api_url = self.openai_compatible_api_url or ""
345
+ primary = self.openai_compatible_model
346
+ for item in raw.split(","):
347
+ model = item.strip()
348
+ if not model:
349
+ continue
350
+ model = self.apply_model_suffix_for_url(model, api_url)
351
+ if model == primary or model in seen:
352
+ continue
353
+ seen.add(model)
354
+ models.append(model)
355
+ return models
356
+
357
+ @property
358
+ def openai_compatible_stream(self) -> bool:
359
+ return (self._get_config_value("OPENAI_COMPATIBLE_STREAM", "false") or "false").lower() in ("true", "1", "yes")
360
+
361
+ def parse_xai_tools(self, raw: str | None = None) -> list[str]:
362
+ raw = raw or self.xai_tools_raw
363
+ tools: list[str] = []
364
+ invalid: list[str] = []
365
+ seen: set[str] = set()
366
+ for item in raw.split(","):
367
+ tool = item.strip().lower()
368
+ if not tool:
369
+ continue
370
+ if tool not in self._ALLOWED_XAI_TOOLS:
371
+ invalid.append(tool)
372
+ continue
373
+ if tool not in seen:
374
+ seen.add(tool)
375
+ tools.append(tool)
376
+ if invalid:
377
+ allowed = ", ".join(sorted(self._ALLOWED_XAI_TOOLS))
378
+ invalid_text = ", ".join(invalid)
379
+ raise ValueError(f"Invalid XAI_TOOLS: {invalid_text}. Supported values: {allowed}")
380
+ return tools
381
+
382
+ def _validated_enum(self, key: str, default: str, allowed: set[str]) -> str:
383
+ value = (self._get_config_value(key, default) or default).strip().lower()
384
+ if value not in allowed:
385
+ allowed_text = ", ".join(sorted(allowed))
386
+ raise ValueError(f"Invalid {key}: {value}. Supported values: {allowed_text}")
387
+ return value
388
+
389
+ def _enum_info(self, key: str, default: str, allowed: set[str]) -> tuple[str, str]:
390
+ value = (self._get_config_value(key, default) or default).strip().lower()
391
+ if value not in allowed:
392
+ allowed_text = ", ".join(sorted(allowed))
393
+ return value, f"Invalid {key}: {value}. Supported values: {allowed_text}"
394
+ return value, ""
395
+
396
+ def _float_value(self, key: str, default: str) -> float:
397
+ value = self._get_config_value(key, default) or default
398
+ try:
399
+ return float(value)
400
+ except (TypeError, ValueError):
401
+ raise ValueError(f"Invalid {key}: {value}. Expected a number.")
402
+
403
+ def _float_info(self, key: str, default: str) -> tuple[float, str]:
404
+ try:
405
+ return self._float_value(key, default), ""
406
+ except ValueError as e:
407
+ return float(default), str(e)
408
+
409
+ def _bounded_float_value(self, key: str, default: str, minimum: float, maximum: float) -> float:
410
+ value = self._float_value(key, default)
411
+ if value < minimum or value > maximum:
412
+ raise ValueError(f"Invalid {key}: {value}. Expected a number between {minimum:g} and {maximum:g}.")
413
+ return value
414
+
415
+ def _bounded_float_info(self, key: str, default: str, minimum: float, maximum: float) -> tuple[float, str]:
416
+ try:
417
+ return self._bounded_float_value(key, default, minimum, maximum), ""
418
+ except ValueError as e:
419
+ return float(default), str(e)
420
+
421
+ @property
422
+ def validation_level(self) -> str:
423
+ return self._validated_enum(
424
+ "SMART_SEARCH_VALIDATION_LEVEL",
425
+ self._DEFAULT_VALIDATION_LEVEL,
426
+ self._ALLOWED_VALIDATION_LEVELS,
427
+ )
428
+
429
+ @property
430
+ def fallback_mode(self) -> str:
431
+ return self._validated_enum(
432
+ "SMART_SEARCH_FALLBACK_MODE",
433
+ self._DEFAULT_FALLBACK_MODE,
434
+ self._ALLOWED_FALLBACK_MODES,
435
+ )
436
+
437
+ @property
438
+ def minimum_profile(self) -> str:
439
+ return self._validated_enum(
440
+ "SMART_SEARCH_MINIMUM_PROFILE",
441
+ self._DEFAULT_MINIMUM_PROFILE,
442
+ self._ALLOWED_MINIMUM_PROFILES,
443
+ )
444
+
445
+ @property
446
+ def intent_router_mode(self) -> str:
447
+ return self._validated_enum(
448
+ "SMART_SEARCH_INTENT_ROUTER",
449
+ self._DEFAULT_INTENT_ROUTER_MODE,
450
+ self._ALLOWED_INTENT_ROUTER_MODES,
451
+ )
452
+
453
+ @property
454
+ def intent_embedding_api_url(self) -> str:
455
+ return self._get_config_value("INTENT_EMBEDDING_API_URL", "") or ""
456
+
457
+ @property
458
+ def intent_embedding_api_key(self) -> str | None:
459
+ return self._get_config_value("INTENT_EMBEDDING_API_KEY")
460
+
461
+ @property
462
+ def intent_embedding_model(self) -> str:
463
+ return self._get_config_value("INTENT_EMBEDDING_MODEL", "") or ""
464
+
465
+ @property
466
+ def intent_embedding_threshold(self) -> float:
467
+ return self._bounded_float_value("INTENT_EMBEDDING_THRESHOLD", self._DEFAULT_INTENT_EMBEDDING_THRESHOLD, 0.0, 1.0)
468
+
469
+ @property
470
+ def intent_embedding_margin(self) -> float:
471
+ return self._bounded_float_value("INTENT_EMBEDDING_MARGIN", self._DEFAULT_INTENT_EMBEDDING_MARGIN, 0.0, 1.0)
472
+
473
+ @property
474
+ def intent_classifier_api_url(self) -> str:
475
+ return self._get_config_value("INTENT_CLASSIFIER_API_URL", "") or ""
476
+
477
+ @property
478
+ def intent_classifier_api_key(self) -> str | None:
479
+ return self._get_config_value("INTENT_CLASSIFIER_API_KEY")
480
+
481
+ @property
482
+ def intent_classifier_model(self) -> str:
483
+ return self._get_config_value("INTENT_CLASSIFIER_MODEL", "") or ""
484
+
485
+ @property
486
+ def intent_router_timeout(self) -> float:
487
+ return self._float_value("INTENT_ROUTER_TIMEOUT_SECONDS", self._DEFAULT_INTENT_ROUTER_TIMEOUT_SECONDS)
488
+
489
+ def _csv_values(self, key: str) -> list[str]:
490
+ raw = self._get_config_value(key, "") or ""
491
+ values: list[str] = []
492
+ seen: set[str] = set()
493
+ for item in raw.split(","):
494
+ value = item.strip().lower()
495
+ if value and value not in seen:
496
+ seen.add(value)
497
+ values.append(value)
498
+ return values
499
+
500
+ @property
501
+ def operation_config(self) -> dict[str, dict]:
502
+ raw = self._get_config_value("SMART_SEARCH_OPERATION_CONFIG", "{}") or "{}"
503
+ try:
504
+ value = json.loads(raw)
505
+ except json.JSONDecodeError as exc:
506
+ raise ValueError(f"Invalid SMART_SEARCH_OPERATION_CONFIG: {exc}") from exc
507
+ if not isinstance(value, dict):
508
+ raise ValueError("Invalid SMART_SEARCH_OPERATION_CONFIG: expected a JSON object")
509
+ return {str(key): item for key, item in value.items() if isinstance(item, dict)}
510
+
511
+ @property
512
+ def tavily_enabled(self) -> bool:
513
+ return (self._get_config_value("TAVILY_ENABLED", "true") or "true").lower() in ("true", "1", "yes")
514
+
515
+ @property
516
+ def tavily_api_url(self) -> str:
517
+ return self._get_config_value("TAVILY_API_URL", "https://api.tavily.com") or "https://api.tavily.com"
518
+
519
+ @property
520
+ def tavily_api_key(self) -> str | None:
521
+ return self._get_config_value("TAVILY_API_KEY")
522
+
523
+ @property
524
+ def tavily_timeout(self) -> float:
525
+ return float(self._get_config_value("TAVILY_TIMEOUT_SECONDS", "30") or "30")
526
+
527
+ @property
528
+ def firecrawl_api_url(self) -> str:
529
+ return self._get_config_value("FIRECRAWL_API_URL", "https://api.firecrawl.dev/v2") or "https://api.firecrawl.dev/v2"
530
+
531
+ @property
532
+ def firecrawl_api_key(self) -> str | None:
533
+ return self._get_config_value("FIRECRAWL_API_KEY")
534
+
535
+ @property
536
+ def log_level(self) -> str:
537
+ return (self._get_config_value("SMART_SEARCH_LOG_LEVEL", "INFO") or "INFO").upper()
538
+
539
+ @property
540
+ def log_dir(self) -> Path:
541
+ log_dir_str = self.log_dir_config_value
542
+ log_dir = Path(log_dir_str)
543
+ if log_dir.is_absolute():
544
+ return log_dir
545
+
546
+ return self.config_file.parent / log_dir
547
+
548
+ @property
549
+ def log_dir_config_value(self) -> str:
550
+ return self._get_config_value("SMART_SEARCH_LOG_DIR", "logs") or "logs"
551
+
552
+ @staticmethod
553
+ def apply_model_suffix_for_url(model: str, api_url: str) -> str:
554
+ if "openrouter" in api_url and ":online" not in model:
555
+ return f"{model}:online"
556
+ return model
557
+
558
+ def _base_model_value(self) -> str:
559
+ return self._DEFAULT_MODEL
560
+
561
+ @staticmethod
562
+ def _mask_api_key(key: str) -> str:
563
+ if not key or len(key) <= 8:
564
+ return "***"
565
+ return f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}"
566
+
567
+ @classmethod
568
+ def _mask_if_secret(cls, key: str, value: str) -> str:
569
+ if "KEY" in key or "TOKEN" in key or "SECRET" in key:
570
+ return cls._mask_api_key(value)
571
+ return value
572
+
573
+ @property
574
+ def output_cleanup_enabled(self) -> bool:
575
+ return (self._get_config_value("SMART_SEARCH_OUTPUT_CLEANUP", "true") or "true").lower() in ("true", "1", "yes")
576
+
577
+ @property
578
+ def log_to_file_enabled(self) -> bool:
579
+ return (self._get_config_value("SMART_SEARCH_LOG_TO_FILE", "false") or "false").lower() in ("true", "1", "yes")
580
+
581
+ @property
582
+ def ssl_verify_enabled(self) -> bool:
583
+ return (self._get_config_value("SSL_VERIFY", "true") or "true").lower() not in ("false", "0", "no")
584
+
585
+ @property
586
+ def exa_api_key(self) -> str | None:
587
+ return self._get_config_value("EXA_API_KEY")
588
+
589
+ @property
590
+ def exa_base_url(self) -> str:
591
+ return self._get_config_value("EXA_BASE_URL", "https://api.exa.ai") or "https://api.exa.ai"
592
+
593
+ @property
594
+ def exa_timeout(self) -> float:
595
+ return float(self._get_config_value("EXA_TIMEOUT_SECONDS", "30") or "30")
596
+
597
+ @property
598
+ def context7_api_key(self) -> str | None:
599
+ return self._get_config_value("CONTEXT7_API_KEY")
600
+
601
+ @property
602
+ def context7_base_url(self) -> str:
603
+ return self._get_config_value("CONTEXT7_BASE_URL", "https://context7.com") or "https://context7.com"
604
+
605
+ @property
606
+ def context7_timeout(self) -> float:
607
+ return float(self._get_config_value("CONTEXT7_TIMEOUT_SECONDS", "30") or "30")
608
+
609
+ @property
610
+ def zhipu_api_key(self) -> str | None:
611
+ return self._get_config_value("ZHIPU_API_KEY")
612
+
613
+ @property
614
+ def zhipu_api_url(self) -> str:
615
+ return self._get_config_value("ZHIPU_API_URL", "https://open.bigmodel.cn/api") or "https://open.bigmodel.cn/api"
616
+
617
+ @property
618
+ def zhipu_search_engine(self) -> str:
619
+ return self._get_config_value("ZHIPU_SEARCH_ENGINE", "search_std") or "search_std"
620
+
621
+ @property
622
+ def zhipu_timeout(self) -> float:
623
+ return float(self._get_config_value("ZHIPU_TIMEOUT_SECONDS", "30") or "30")
624
+
625
+ @property
626
+ def zhipu_mcp_api_key(self) -> str | None:
627
+ return self._get_config_value("ZHIPU_MCP_API_KEY")
628
+
629
+ @property
630
+ def zhipu_mcp_search_api_url(self) -> str:
631
+ return self._get_config_value(
632
+ "ZHIPU_MCP_SEARCH_API_URL",
633
+ "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp",
634
+ ) or "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp"
635
+
636
+ @property
637
+ def zhipu_mcp_reader_api_url(self) -> str:
638
+ return self._get_config_value(
639
+ "ZHIPU_MCP_READER_API_URL",
640
+ "https://open.bigmodel.cn/api/mcp/web_reader/mcp",
641
+ ) or "https://open.bigmodel.cn/api/mcp/web_reader/mcp"
642
+
643
+ @property
644
+ def zhipu_mcp_zread_api_url(self) -> str:
645
+ return self._get_config_value(
646
+ "ZHIPU_MCP_ZREAD_API_URL",
647
+ "https://open.bigmodel.cn/api/mcp/zread/mcp",
648
+ ) or "https://open.bigmodel.cn/api/mcp/zread/mcp"
649
+
650
+ @property
651
+ def zhipu_mcp_timeout(self) -> float:
652
+ return float(self._get_config_value("ZHIPU_MCP_TIMEOUT_SECONDS", "30") or "30")
653
+
654
+ @property
655
+ def jina_api_key(self) -> str | None:
656
+ return self._get_config_value("JINA_API_KEY")
657
+
658
+ @property
659
+ def jina_reader_api_url(self) -> str:
660
+ return self._get_config_value("JINA_READER_API_URL", "https://r.jina.ai") or "https://r.jina.ai"
661
+
662
+ @property
663
+ def jina_respond_with(self) -> str:
664
+ return self._get_config_value("JINA_RESPOND_WITH", "") or ""
665
+
666
+ @property
667
+ def jina_timeout(self) -> float:
668
+ return float(self._get_config_value("JINA_TIMEOUT_SECONDS", "30") or "30")
669
+
670
+ def get_config_info(self) -> dict:
671
+ config_parameter_errors: list[str] = []
672
+ explicit_main_configured = bool(
673
+ self.xai_api_key
674
+ or (self.openai_compatible_api_url and self.openai_compatible_api_key)
675
+ )
676
+ if explicit_main_configured:
677
+ config_status = "ok: 配置完整"
678
+ else:
679
+ config_status = f"config_error: {self._SETUP_COMMAND}"
680
+
681
+ validation_level, validation_error = self._enum_info(
682
+ "SMART_SEARCH_VALIDATION_LEVEL",
683
+ self._DEFAULT_VALIDATION_LEVEL,
684
+ self._ALLOWED_VALIDATION_LEVELS,
685
+ )
686
+ fallback_mode, fallback_error = self._enum_info(
687
+ "SMART_SEARCH_FALLBACK_MODE",
688
+ self._DEFAULT_FALLBACK_MODE,
689
+ self._ALLOWED_FALLBACK_MODES,
690
+ )
691
+ minimum_profile, minimum_error = self._enum_info(
692
+ "SMART_SEARCH_MINIMUM_PROFILE",
693
+ self._DEFAULT_MINIMUM_PROFILE,
694
+ self._ALLOWED_MINIMUM_PROFILES,
695
+ )
696
+ intent_router_mode, intent_router_error = self._enum_info(
697
+ "SMART_SEARCH_INTENT_ROUTER",
698
+ self._DEFAULT_INTENT_ROUTER_MODE,
699
+ self._ALLOWED_INTENT_ROUTER_MODES,
700
+ )
701
+ intent_router_timeout, intent_router_timeout_error = self._float_info(
702
+ "INTENT_ROUTER_TIMEOUT_SECONDS",
703
+ self._DEFAULT_INTENT_ROUTER_TIMEOUT_SECONDS,
704
+ )
705
+ intent_embedding_threshold, intent_embedding_threshold_error = self._bounded_float_info(
706
+ "INTENT_EMBEDDING_THRESHOLD",
707
+ self._DEFAULT_INTENT_EMBEDDING_THRESHOLD,
708
+ 0.0,
709
+ 1.0,
710
+ )
711
+ intent_embedding_margin, intent_embedding_margin_error = self._bounded_float_info(
712
+ "INTENT_EMBEDDING_MARGIN",
713
+ self._DEFAULT_INTENT_EMBEDDING_MARGIN,
714
+ 0.0,
715
+ 1.0,
716
+ )
717
+ config_parameter_errors.extend(
718
+ error
719
+ for error in (
720
+ validation_error,
721
+ fallback_error,
722
+ minimum_error,
723
+ intent_router_error,
724
+ intent_router_timeout_error,
725
+ intent_embedding_threshold_error,
726
+ intent_embedding_margin_error,
727
+ )
728
+ if error
729
+ )
730
+ if config_parameter_errors and config_status.startswith("ok:"):
731
+ config_status = f"config_error: {'; '.join(config_parameter_errors)}"
732
+
733
+ return {
734
+ "XAI_API_URL": self.xai_api_url,
735
+ "XAI_API_KEY": self._mask_api_key(self.xai_api_key) if self.xai_api_key else "未配置",
736
+ "XAI_MODEL": self.xai_model,
737
+ "XAI_TOOLS": self.xai_tools_raw,
738
+ "OPENAI_COMPATIBLE_API_URL": self.openai_compatible_api_url or "未配置",
739
+ "OPENAI_COMPATIBLE_API_KEY": self._mask_api_key(self.openai_compatible_api_key) if self.openai_compatible_api_key else "未配置",
740
+ "OPENAI_COMPATIBLE_MODEL": self.openai_compatible_model,
741
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS": ",".join(self.openai_compatible_fallback_models),
742
+ "OPENAI_COMPATIBLE_STREAM": self.openai_compatible_stream,
743
+ "SMART_SEARCH_VALIDATION_LEVEL": validation_level,
744
+ "SMART_SEARCH_FALLBACK_MODE": fallback_mode,
745
+ "SMART_SEARCH_MINIMUM_PROFILE": minimum_profile,
746
+ "SMART_SEARCH_OPERATION_CONFIG": self.operation_config,
747
+ "SMART_SEARCH_INTENT_ROUTER": intent_router_mode,
748
+ "INTENT_EMBEDDING_API_URL": self.intent_embedding_api_url or "未配置",
749
+ "INTENT_EMBEDDING_API_KEY": self._mask_api_key(self.intent_embedding_api_key) if self.intent_embedding_api_key else "未配置",
750
+ "INTENT_EMBEDDING_MODEL": self.intent_embedding_model or "未配置",
751
+ "INTENT_EMBEDDING_THRESHOLD": intent_embedding_threshold,
752
+ "INTENT_EMBEDDING_MARGIN": intent_embedding_margin,
753
+ "INTENT_CLASSIFIER_API_URL": self.intent_classifier_api_url or "未配置",
754
+ "INTENT_CLASSIFIER_API_KEY": self._mask_api_key(self.intent_classifier_api_key) if self.intent_classifier_api_key else "未配置",
755
+ "INTENT_CLASSIFIER_MODEL": self.intent_classifier_model or "未配置",
756
+ "INTENT_ROUTER_TIMEOUT_SECONDS": intent_router_timeout,
757
+ "SMART_SEARCH_DEBUG": self.debug_enabled,
758
+ "SMART_SEARCH_LOG_LEVEL": self.log_level,
759
+ "SMART_SEARCH_LOG_DIR": self.log_dir_config_value,
760
+ "SMART_SEARCH_RETRY_MAX_ATTEMPTS": self.retry_max_attempts,
761
+ "SMART_SEARCH_RETRY_MULTIPLIER": self.retry_multiplier,
762
+ "SMART_SEARCH_RETRY_MAX_WAIT": self.retry_max_wait,
763
+ "TAVILY_API_URL": self.tavily_api_url,
764
+ "TAVILY_ENABLED": self.tavily_enabled,
765
+ "TAVILY_API_KEY": self._mask_api_key(self.tavily_api_key) if self.tavily_api_key else "未配置",
766
+ "TAVILY_TIMEOUT_SECONDS": self.tavily_timeout,
767
+ "FIRECRAWL_API_URL": self.firecrawl_api_url,
768
+ "FIRECRAWL_API_KEY": self._mask_api_key(self.firecrawl_api_key) if self.firecrawl_api_key else "未配置",
769
+ "SMART_SEARCH_OUTPUT_CLEANUP": self.output_cleanup_enabled,
770
+ "SMART_SEARCH_LOG_TO_FILE": self.log_to_file_enabled,
771
+ "SSL_VERIFY": self.ssl_verify_enabled,
772
+ "EXA_API_KEY": self._mask_api_key(self.exa_api_key) if self.exa_api_key else "未配置",
773
+ "EXA_BASE_URL": self.exa_base_url,
774
+ "EXA_TIMEOUT_SECONDS": self.exa_timeout,
775
+ "CONTEXT7_API_KEY": self._mask_api_key(self.context7_api_key) if self.context7_api_key else "未配置",
776
+ "CONTEXT7_BASE_URL": self.context7_base_url,
777
+ "CONTEXT7_TIMEOUT_SECONDS": self.context7_timeout,
778
+ "ZHIPU_API_KEY": self._mask_api_key(self.zhipu_api_key) if self.zhipu_api_key else "未配置",
779
+ "ZHIPU_API_URL": self.zhipu_api_url,
780
+ "ZHIPU_SEARCH_ENGINE": self.zhipu_search_engine,
781
+ "ZHIPU_TIMEOUT_SECONDS": self.zhipu_timeout,
782
+ "ZHIPU_MCP_API_KEY": self._mask_api_key(self.zhipu_mcp_api_key) if self.zhipu_mcp_api_key else "未配置",
783
+ "ZHIPU_MCP_SEARCH_API_URL": self.zhipu_mcp_search_api_url,
784
+ "ZHIPU_MCP_READER_API_URL": self.zhipu_mcp_reader_api_url,
785
+ "ZHIPU_MCP_ZREAD_API_URL": self.zhipu_mcp_zread_api_url,
786
+ "ZHIPU_MCP_TIMEOUT_SECONDS": self.zhipu_mcp_timeout,
787
+ "JINA_API_KEY": self._mask_api_key(self.jina_api_key) if self.jina_api_key else "未配置",
788
+ "JINA_READER_API_URL": self.jina_reader_api_url,
789
+ "JINA_RESPOND_WITH": self.jina_respond_with,
790
+ "JINA_TIMEOUT_SECONDS": self.jina_timeout,
791
+ "primary_api_mode": "xai-responses" if self.xai_api_key else ("chat-completions" if self.openai_compatible_api_url and self.openai_compatible_api_key else "未配置"),
792
+ "primary_api_mode_source": "config_file" if explicit_main_configured else "default",
793
+ "config_file": str(self.config_file),
794
+ "config_dir": str(self.config_file.parent),
795
+ "config_dir_source": self.config_dir_source,
796
+ "default_config_file": str(self._default_config_dir() / "config.json"),
797
+ "legacy_windows_config_file": str(self._legacy_windows_config_dir() / "config.json") if sys.platform.startswith("win") else "",
798
+ "legacy_windows_config_exists": (self._legacy_windows_config_dir() / "config.json").exists() if sys.platform.startswith("win") else False,
799
+ "config_dir_override_value": self._config_dir_override_value(),
800
+ "config_dir_override_matches_default": self._config_dir_override_matches_default(),
801
+ "log_dir_config_value": self.log_dir_config_value,
802
+ "resolved_log_dir": str(self.log_dir),
803
+ "file_logging_enabled": self.debug_enabled or self.log_to_file_enabled,
804
+ "config_sources": self.get_config_sources(),
805
+ "config_parameter_errors": config_parameter_errors,
806
+ "config_status": config_status
807
+ }
808
+
809
+ config = Config()