@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,541 @@
1
+ import httpx
2
+ import json
3
+ import logging
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, List, Optional
8
+ from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
9
+ from tenacity.wait import wait_base
10
+ from .base import BaseSearchProvider, SearchResult
11
+ from ..utils import search_prompt, fetch_prompt, url_describe_prompt, rank_sources_prompt
12
+ from ..logger import log_info
13
+ from ..config import config
14
+
15
+ _logger = logging.getLogger(__name__)
16
+ _ssl_warning_emitted = False
17
+ _STREAM_BREAKERS: dict[tuple[str, str], dict[str, Any]] = {}
18
+ STREAM_BREAKER_FAILURE_THRESHOLD = 2
19
+ STREAM_BREAKER_COOLDOWN_SECONDS = 600.0
20
+
21
+
22
+ def get_local_time_info() -> str:
23
+ try:
24
+ local_tz = datetime.now().astimezone().tzinfo
25
+ local_now = datetime.now(local_tz)
26
+ except Exception:
27
+ local_now = datetime.now(timezone.utc)
28
+
29
+ weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
30
+ weekday = weekdays_cn[local_now.weekday()]
31
+
32
+ return (
33
+ f"[Current Time Context]\n"
34
+ f"- Date: {local_now.strftime('%Y-%m-%d')} ({weekday})\n"
35
+ f"- Time: {local_now.strftime('%H:%M:%S')}\n"
36
+ f"- Timezone: {local_now.tzname() or 'Local'}\n"
37
+ )
38
+
39
+
40
+ RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
41
+
42
+
43
+ def _is_retryable_exception(exc) -> bool:
44
+ if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError, httpx.RemoteProtocolError)):
45
+ return True
46
+ if isinstance(exc, httpx.HTTPStatusError):
47
+ return exc.response.status_code in RETRYABLE_STATUS_CODES
48
+ return False
49
+
50
+
51
+ def _elapsed_ms(start: float) -> float:
52
+ return round((time.time() - start) * 1000, 2)
53
+
54
+
55
+ def _transport_error_type(exc: BaseException) -> str:
56
+ if isinstance(exc, httpx.TimeoutException):
57
+ return "timeout"
58
+ if isinstance(exc, (httpx.HTTPStatusError, httpx.RequestError)):
59
+ return "network_error"
60
+ return "runtime_error"
61
+
62
+
63
+ def _transport_error_message(exc: BaseException) -> str:
64
+ if isinstance(exc, httpx.HTTPStatusError):
65
+ body = exc.response.text[:300] if exc.response is not None else str(exc)
66
+ status = exc.response.status_code if exc.response is not None else "unknown"
67
+ return f"HTTP {status}: {body}"
68
+ return str(exc)
69
+
70
+
71
+ def _stream_breaker_key(api_url: str, model: str) -> tuple[str, str]:
72
+ return (api_url.rstrip("/"), model)
73
+
74
+
75
+ def reset_openai_compatible_breakers() -> None:
76
+ _STREAM_BREAKERS.clear()
77
+
78
+
79
+ class _WaitWithRetryAfter(wait_base):
80
+
81
+ def __init__(self, multiplier: float, max_wait: int):
82
+ self._base_wait = wait_random_exponential(multiplier=multiplier, max=max_wait)
83
+ self._protocol_error_base = 3.0
84
+
85
+ def __call__(self, retry_state):
86
+ if retry_state.outcome and retry_state.outcome.failed:
87
+ exc = retry_state.outcome.exception()
88
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
89
+ retry_after = self._parse_retry_after(exc.response)
90
+ if retry_after is not None:
91
+ return retry_after
92
+ if isinstance(exc, httpx.RemoteProtocolError):
93
+ return self._base_wait(retry_state) + self._protocol_error_base
94
+ return self._base_wait(retry_state)
95
+
96
+ def _parse_retry_after(self, response: httpx.Response) -> Optional[float]:
97
+ header = response.headers.get("Retry-After")
98
+ if not header:
99
+ return None
100
+ header = header.strip()
101
+
102
+ if header.isdigit():
103
+ return float(header)
104
+
105
+ try:
106
+ retry_dt = parsedate_to_datetime(header)
107
+ if retry_dt.tzinfo is None:
108
+ retry_dt = retry_dt.replace(tzinfo=timezone.utc)
109
+ delay = (retry_dt - datetime.now(timezone.utc)).total_seconds()
110
+ return max(0.0, delay)
111
+ except (TypeError, ValueError):
112
+ return None
113
+
114
+
115
+ class OpenAICompatibleSearchProvider(BaseSearchProvider):
116
+ def __init__(self, api_url: str, api_key: str, model: str = "grok-4-fast", stream: bool = False):
117
+ super().__init__(api_url.rstrip("/"), api_key)
118
+ self.model = model
119
+ self.stream = stream
120
+ self.last_transport_attempts: list[dict[str, Any]] = []
121
+
122
+ def get_provider_name(self) -> str:
123
+ return "OpenAI-compatible"
124
+
125
+ def _build_api_headers(self) -> dict:
126
+ return {
127
+ "Authorization": f"Bearer {self.api_key}",
128
+ "Content-Type": "application/json",
129
+ "Accept": "application/json, text/event-stream",
130
+ "User-Agent": "smart-search/0.1.0",
131
+ }
132
+
133
+ def _get_ssl_verify(self) -> bool:
134
+ global _ssl_warning_emitted
135
+ verify = config.ssl_verify_enabled
136
+ if not verify and not _ssl_warning_emitted:
137
+ _ssl_warning_emitted = True
138
+ _logger.warning("SSL_VERIFY=false: OpenAI-compatible API 请求已禁用 SSL 证书验证,存在安全风险")
139
+ return verify
140
+
141
+ async def search(self, query: str, platform: str = "", ctx=None) -> List[SearchResult]:
142
+ headers = self._build_api_headers()
143
+ platform_prompt = ""
144
+
145
+ if platform:
146
+ platform_prompt = "\n\nYou should search the web for the information you need, and focus on these platform: " + platform + "\n"
147
+
148
+ time_context = get_local_time_info() + "\n"
149
+
150
+ payload = {
151
+ "model": self.model,
152
+ "messages": [
153
+ {
154
+ "role": "system",
155
+ "content": search_prompt,
156
+ },
157
+ {"role": "user", "content": time_context + query + platform_prompt},
158
+ ],
159
+ "stream": self.stream,
160
+ }
161
+
162
+ await log_info(ctx, f"platform_prompt: { query + platform_prompt}", config.debug_enabled)
163
+
164
+ return await self._execute_with_transport_fallback(headers, payload, ctx)
165
+
166
+ async def fetch(self, url: str, ctx=None) -> str:
167
+ headers = self._build_api_headers()
168
+ payload = {
169
+ "model": self.model,
170
+ "messages": [
171
+ {
172
+ "role": "system",
173
+ "content": fetch_prompt,
174
+ },
175
+ {"role": "user", "content": url + "\n获取该网页内容并返回其结构化Markdown格式" },
176
+ ],
177
+ "stream": self.stream,
178
+ }
179
+ return await self._execute_with_transport_fallback(headers, payload, ctx)
180
+
181
+ def _breaker_state(self) -> dict[str, Any]:
182
+ state = _STREAM_BREAKERS.get(_stream_breaker_key(self.api_url, self.model), {})
183
+ opened_until = float(state.get("opened_until") or 0.0)
184
+ now = time.monotonic()
185
+ if opened_until and opened_until > now:
186
+ return {
187
+ "state": "open",
188
+ "opened_until_seconds": round(opened_until - now, 3),
189
+ "consecutive_failures": int(state.get("consecutive_failures") or 0),
190
+ }
191
+ if opened_until and opened_until <= now:
192
+ _STREAM_BREAKERS.pop(_stream_breaker_key(self.api_url, self.model), None)
193
+ return {"state": "closed", "consecutive_failures": int(state.get("consecutive_failures") or 0)}
194
+
195
+ def _record_stream_success(self) -> None:
196
+ _STREAM_BREAKERS.pop(_stream_breaker_key(self.api_url, self.model), None)
197
+
198
+ def _record_stream_failure(self) -> dict[str, Any]:
199
+ key = _stream_breaker_key(self.api_url, self.model)
200
+ state = _STREAM_BREAKERS.setdefault(key, {"consecutive_failures": 0, "opened_until": 0.0})
201
+ state["consecutive_failures"] = int(state.get("consecutive_failures") or 0) + 1
202
+ if state["consecutive_failures"] >= STREAM_BREAKER_FAILURE_THRESHOLD:
203
+ state["opened_until"] = time.monotonic() + STREAM_BREAKER_COOLDOWN_SECONDS
204
+ return self._breaker_state()
205
+
206
+ def _transport_attempt(
207
+ self,
208
+ transport: str,
209
+ status: str,
210
+ start: float,
211
+ *,
212
+ error_type: str = "",
213
+ error: str = "",
214
+ breaker_state: dict[str, Any] | None = None,
215
+ fallback_from_transport: str = "",
216
+ ) -> dict[str, Any]:
217
+ attempt: dict[str, Any] = {
218
+ "transport": transport,
219
+ "status": status,
220
+ "error_type": error_type,
221
+ "error": error,
222
+ "elapsed_ms": _elapsed_ms(start),
223
+ "result_count": 1 if status == "ok" else 0,
224
+ "model": self.model,
225
+ }
226
+ if breaker_state:
227
+ attempt["breaker_state"] = breaker_state
228
+ if fallback_from_transport:
229
+ attempt["fallback_from_transport"] = fallback_from_transport
230
+ return attempt
231
+
232
+ async def _execute_with_transport_fallback(self, headers: dict, payload: dict, ctx=None) -> str:
233
+ self.last_transport_attempts = []
234
+ if not self.stream:
235
+ payload["stream"] = False
236
+ start = time.time()
237
+ try:
238
+ content = await self._execute_completion_with_retry(headers, payload, ctx)
239
+ self.last_transport_attempts.append(self._transport_attempt("non_stream", "ok" if content else "empty", start))
240
+ return content
241
+ except Exception as e:
242
+ self.last_transport_attempts.append(
243
+ self._transport_attempt(
244
+ "non_stream",
245
+ "error",
246
+ start,
247
+ error_type=_transport_error_type(e),
248
+ error=_transport_error_message(e),
249
+ )
250
+ )
251
+ raise
252
+
253
+ breaker_state = self._breaker_state()
254
+ if breaker_state.get("state") == "open":
255
+ self.last_transport_attempts.append(
256
+ self._transport_attempt("stream", "skipped", time.time(), breaker_state=breaker_state, error="stream breaker open")
257
+ )
258
+ else:
259
+ payload["stream"] = True
260
+ stream_start = time.time()
261
+ try:
262
+ content = await self._execute_stream_with_retry(headers, payload, ctx)
263
+ if content and content.strip():
264
+ self._record_stream_success()
265
+ self.last_transport_attempts.append(
266
+ self._transport_attempt("stream", "ok", stream_start, breaker_state=self._breaker_state())
267
+ )
268
+ return content
269
+ breaker_state = self._record_stream_failure()
270
+ self.last_transport_attempts.append(
271
+ self._transport_attempt(
272
+ "stream",
273
+ "empty",
274
+ stream_start,
275
+ error_type="network_error",
276
+ error="OpenAI-compatible stream returned empty content",
277
+ breaker_state=breaker_state,
278
+ )
279
+ )
280
+ except Exception as e:
281
+ breaker_state = self._record_stream_failure()
282
+ self.last_transport_attempts.append(
283
+ self._transport_attempt(
284
+ "stream",
285
+ "error",
286
+ stream_start,
287
+ error_type=_transport_error_type(e),
288
+ error=_transport_error_message(e),
289
+ breaker_state=breaker_state,
290
+ )
291
+ )
292
+ if not _is_retryable_exception(e):
293
+ raise
294
+
295
+ payload["stream"] = False
296
+ completion_start = time.time()
297
+ try:
298
+ content = await self._execute_completion_with_retry(headers, payload, ctx)
299
+ self.last_transport_attempts.append(
300
+ self._transport_attempt(
301
+ "non_stream",
302
+ "ok" if content else "empty",
303
+ completion_start,
304
+ error_type="" if content else "network_error",
305
+ error="" if content else "OpenAI-compatible non-stream returned empty content",
306
+ fallback_from_transport="stream",
307
+ )
308
+ )
309
+ return content
310
+ except Exception as e:
311
+ self.last_transport_attempts.append(
312
+ self._transport_attempt(
313
+ "non_stream",
314
+ "error",
315
+ completion_start,
316
+ error_type=_transport_error_type(e),
317
+ error=_transport_error_message(e),
318
+ fallback_from_transport="stream",
319
+ )
320
+ )
321
+ raise
322
+
323
+ async def _parse_streaming_response(self, response, ctx=None) -> str:
324
+ content = ""
325
+ full_body_buffer = []
326
+
327
+ async for line in response.aiter_lines():
328
+ line = line.strip()
329
+ if not line:
330
+ continue
331
+
332
+ full_body_buffer.append(line)
333
+
334
+ if line.startswith("data:"):
335
+ if line in ("data: [DONE]", "data:[DONE]"):
336
+ continue
337
+ try:
338
+ json_str = line[5:].lstrip()
339
+ data = json.loads(json_str)
340
+ choices = data.get("choices", [])
341
+ if choices and len(choices) > 0:
342
+ delta = choices[0].get("delta", {})
343
+ if "content" in delta:
344
+ content += delta["content"]
345
+ except (json.JSONDecodeError, IndexError):
346
+ continue
347
+
348
+ if not content and full_body_buffer:
349
+ try:
350
+ full_text = "".join(full_body_buffer)
351
+ data = json.loads(full_text)
352
+ if "choices" in data and len(data["choices"]) > 0:
353
+ message = data["choices"][0].get("message", {})
354
+ content = message.get("content", "")
355
+ except json.JSONDecodeError:
356
+ pass
357
+
358
+ await log_info(ctx, f"content: {content}", config.debug_enabled)
359
+
360
+ return content
361
+
362
+ async def _execute_stream_with_retry(self, headers: dict, payload: dict, ctx=None) -> str:
363
+ timeout = httpx.Timeout(connect=6.0, read=120.0, write=10.0, pool=None)
364
+
365
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=self._get_ssl_verify()) as client:
366
+ async for attempt in AsyncRetrying(
367
+ stop=stop_after_attempt(config.retry_max_attempts + 1),
368
+ wait=_WaitWithRetryAfter(config.retry_multiplier, config.retry_max_wait),
369
+ retry=retry_if_exception(_is_retryable_exception),
370
+ reraise=True,
371
+ ):
372
+ with attempt:
373
+ async with client.stream(
374
+ "POST",
375
+ f"{self.api_url}/chat/completions",
376
+ headers=headers,
377
+ json=payload,
378
+ ) as response:
379
+ response.raise_for_status()
380
+ return await self._parse_streaming_response(response, ctx)
381
+
382
+ async def _parse_completion_response(self, response: httpx.Response, ctx=None) -> str:
383
+ """解析非流式 completion 响应,兼容 JSON 和 SSE 文本 fallback"""
384
+ content = ""
385
+ body_text = response.text or ""
386
+ sources: list[dict] = []
387
+
388
+ try:
389
+ data = response.json()
390
+ except Exception:
391
+ data = None
392
+
393
+ if isinstance(data, dict):
394
+ sources = self._extract_citations(data)
395
+ choices = data.get("choices", [])
396
+ if choices:
397
+ message = choices[0].get("message", {})
398
+ if isinstance(message, dict):
399
+ content = message.get("content", "") or ""
400
+ message_citations = self._normalize_citations(message.get("citations"))
401
+ if message_citations:
402
+ sources = self._merge_citations(sources, message_citations)
403
+
404
+ # SSE fallback: 部分中转站即使设置 stream=False 仍可能返回 SSE 格式
405
+ if not content and body_text.lstrip().startswith("data:"):
406
+ class _LineResponse:
407
+ def __init__(self, text: str):
408
+ self._lines = text.splitlines()
409
+
410
+ async def aiter_lines(self):
411
+ for line in self._lines:
412
+ yield line
413
+
414
+ content = await self._parse_streaming_response(_LineResponse(body_text), ctx)
415
+
416
+ if content and sources:
417
+ content = f"{content.rstrip()}\n\nsources({json.dumps(sources, ensure_ascii=False)})"
418
+
419
+ await log_info(ctx, f"content: {content}", config.debug_enabled)
420
+
421
+ return content
422
+
423
+ def _extract_citations(self, data: dict) -> list[dict]:
424
+ sources = self._normalize_citations(data.get("citations"))
425
+ for choice in data.get("choices", []) or []:
426
+ if not isinstance(choice, dict):
427
+ continue
428
+ message = choice.get("message")
429
+ if isinstance(message, dict):
430
+ sources = self._merge_citations(sources, self._normalize_citations(message.get("citations")))
431
+ return sources
432
+
433
+ def _normalize_citations(self, citations) -> list[dict]:
434
+ if not citations:
435
+ return []
436
+ if not isinstance(citations, list):
437
+ citations = [citations]
438
+
439
+ normalized: list[dict] = []
440
+ seen: set[str] = set()
441
+ for item in citations:
442
+ source: dict = {}
443
+ if isinstance(item, str):
444
+ url = item.strip()
445
+ if not url.startswith(("http://", "https://")):
446
+ continue
447
+ source["url"] = url
448
+ elif isinstance(item, dict):
449
+ url = item.get("url") or item.get("href") or item.get("link")
450
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):
451
+ continue
452
+ source["url"] = url
453
+ title = item.get("title") or item.get("name") or item.get("label")
454
+ if isinstance(title, str) and title.strip():
455
+ source["title"] = title.strip()
456
+ else:
457
+ continue
458
+
459
+ if source["url"] in seen:
460
+ continue
461
+ seen.add(source["url"])
462
+ normalized.append(source)
463
+ return normalized
464
+
465
+ def _merge_citations(self, *source_lists: list[dict]) -> list[dict]:
466
+ merged: list[dict] = []
467
+ seen: set[str] = set()
468
+ for source_list in source_lists:
469
+ for item in source_list or []:
470
+ url = item.get("url")
471
+ if not isinstance(url, str) or not url or url in seen:
472
+ continue
473
+ seen.add(url)
474
+ merged.append(item)
475
+ return merged
476
+
477
+ async def _execute_completion_with_retry(self, headers: dict, payload: dict, ctx=None) -> str:
478
+ """执行带重试机制的非流式 HTTP 请求,兼容上游返回 JSON 或 SSE 文本"""
479
+ timeout = httpx.Timeout(connect=6.0, read=120.0, write=10.0, pool=None)
480
+
481
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=self._get_ssl_verify()) as client:
482
+ async for attempt in AsyncRetrying(
483
+ stop=stop_after_attempt(config.retry_max_attempts + 1),
484
+ wait=_WaitWithRetryAfter(config.retry_multiplier, config.retry_max_wait),
485
+ retry=retry_if_exception(_is_retryable_exception),
486
+ reraise=True,
487
+ ):
488
+ with attempt:
489
+ response = await client.post(
490
+ f"{self.api_url}/chat/completions",
491
+ headers=headers,
492
+ json=payload,
493
+ )
494
+ response.raise_for_status()
495
+ return await self._parse_completion_response(response, ctx)
496
+
497
+ async def describe_url(self, url: str, ctx=None) -> dict:
498
+ headers = self._build_api_headers()
499
+ payload = {
500
+ "model": self.model,
501
+ "messages": [
502
+ {"role": "system", "content": url_describe_prompt},
503
+ {"role": "user", "content": url},
504
+ ],
505
+ "stream": False,
506
+ }
507
+ result = await self._execute_completion_with_retry(headers, payload, ctx)
508
+ title, extracts = url, ""
509
+ for line in result.strip().splitlines():
510
+ if line.startswith("Title:"):
511
+ title = line[6:].strip() or url
512
+ elif line.startswith("Extracts:"):
513
+ extracts = line[9:].strip()
514
+ return {"title": title, "extracts": extracts, "url": url}
515
+
516
+ async def rank_sources(self, query: str, sources_text: str, total: int, ctx=None) -> list[int]:
517
+ """让 OpenAI-compatible 模型按查询相关度对信源排序,返回排序后的序号列表"""
518
+ headers = self._build_api_headers()
519
+ payload = {
520
+ "model": self.model,
521
+ "messages": [
522
+ {"role": "system", "content": rank_sources_prompt},
523
+ {"role": "user", "content": f"Query: {query}\n\n{sources_text}"},
524
+ ],
525
+ "stream": False,
526
+ }
527
+ result = await self._execute_completion_with_retry(headers, payload, ctx)
528
+ order: list[int] = []
529
+ seen: set[int] = set()
530
+ for token in result.strip().split():
531
+ try:
532
+ n = int(token)
533
+ if 1 <= n <= total and n not in seen:
534
+ seen.add(n)
535
+ order.append(n)
536
+ except ValueError:
537
+ continue
538
+ for i in range(1, total + 1):
539
+ if i not in seen:
540
+ order.append(i)
541
+ return order
@@ -0,0 +1,117 @@
1
+ import json
2
+ import logging
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt
7
+
8
+ from .base import BaseSearchProvider
9
+ from .openai_compatible import _WaitWithRetryAfter, _is_retryable_exception, get_local_time_info
10
+ from ..config import config
11
+ from ..logger import log_info
12
+ from ..utils import search_prompt
13
+
14
+
15
+ _logger = logging.getLogger(__name__)
16
+ _ssl_warning_emitted = False
17
+
18
+
19
+ class XAIResponsesSearchProvider(BaseSearchProvider):
20
+ def __init__(self, api_url: str, api_key: str, model: str = "grok-4-fast", tools: list[str] | None = None):
21
+ super().__init__(api_url.rstrip("/"), api_key)
22
+ self.model = model
23
+ self.tools = tools or []
24
+
25
+ def get_provider_name(self) -> str:
26
+ return "xAI Responses"
27
+
28
+ def _build_api_headers(self) -> dict:
29
+ return {
30
+ "Authorization": f"Bearer {self.api_key}",
31
+ "Content-Type": "application/json",
32
+ "Accept": "application/json",
33
+ "User-Agent": "smart-search/0.1.0",
34
+ }
35
+
36
+ def _get_ssl_verify(self) -> bool:
37
+ global _ssl_warning_emitted
38
+ verify = config.ssl_verify_enabled
39
+ if not verify and not _ssl_warning_emitted:
40
+ _ssl_warning_emitted = True
41
+ _logger.warning("SSL_VERIFY=false: xAI Responses API 请求已禁用 SSL 证书验证,存在安全风险")
42
+ return verify
43
+
44
+ def _build_search_payload(self, query: str, platform: str = "") -> dict[str, Any]:
45
+ platform_prompt = ""
46
+ if platform:
47
+ platform_prompt = "\n\nYou should search the web for the information you need, and focus on these platform: " + platform + "\n"
48
+ time_context = get_local_time_info() + "\n"
49
+ payload: dict[str, Any] = {
50
+ "model": self.model,
51
+ "instructions": search_prompt,
52
+ "input": [{"role": "user", "content": time_context + query + platform_prompt}],
53
+ "stream": False,
54
+ "tools": [{"type": tool} for tool in self.tools],
55
+ }
56
+ return payload
57
+
58
+ async def search(self, query: str, platform: str = "", ctx=None) -> str:
59
+ payload = self._build_search_payload(query, platform)
60
+ await log_info(ctx, f"platform_prompt: {query}", config.debug_enabled)
61
+ return await self._execute_response_with_retry(self._build_api_headers(), payload, ctx)
62
+
63
+ async def _execute_response_with_retry(self, headers: dict, payload: dict, ctx=None) -> str:
64
+ timeout = httpx.Timeout(connect=6.0, read=120.0, write=10.0, pool=None)
65
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=self._get_ssl_verify()) as client:
66
+ async for attempt in AsyncRetrying(
67
+ stop=stop_after_attempt(config.retry_max_attempts + 1),
68
+ wait=_WaitWithRetryAfter(config.retry_multiplier, config.retry_max_wait),
69
+ retry=retry_if_exception(_is_retryable_exception),
70
+ reraise=True,
71
+ ):
72
+ with attempt:
73
+ response = await client.post(
74
+ f"{self.api_url}/responses",
75
+ headers=headers,
76
+ json=payload,
77
+ )
78
+ response.raise_for_status()
79
+ return await self._parse_response(response, ctx)
80
+ return ""
81
+
82
+ async def _parse_response(self, response: httpx.Response, ctx=None) -> str:
83
+ data = response.json()
84
+ text_parts: list[str] = []
85
+ sources: list[dict[str, str]] = []
86
+ seen: set[str] = set()
87
+
88
+ for item in data.get("output", []) if isinstance(data, dict) else []:
89
+ if not isinstance(item, dict):
90
+ continue
91
+ for content in item.get("content", []) or []:
92
+ if not isinstance(content, dict):
93
+ continue
94
+ if content.get("type") != "output_text":
95
+ continue
96
+ text = content.get("text")
97
+ if isinstance(text, str) and text:
98
+ text_parts.append(text)
99
+ for annotation in content.get("annotations", []) or []:
100
+ if not isinstance(annotation, dict) or annotation.get("type") != "url_citation":
101
+ continue
102
+ url = annotation.get("url")
103
+ if not isinstance(url, str) or not url.startswith(("http://", "https://")) or url in seen:
104
+ continue
105
+ seen.add(url)
106
+ source: dict[str, str] = {"url": url}
107
+ title = annotation.get("title")
108
+ if isinstance(title, str) and title.strip():
109
+ source["title"] = title.strip()
110
+ sources.append(source)
111
+
112
+ answer = "\n\n".join(part.strip() for part in text_parts if part.strip()).strip()
113
+ if sources:
114
+ answer = f"{answer}\n\nsources({json.dumps(sources, ensure_ascii=False)})".strip()
115
+
116
+ await log_info(ctx, f"content: {answer}", config.debug_enabled)
117
+ return answer