@shirlytaylor73/smart-search 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh-CN.md +180 -0
- package/npm/bin/smart-search.js +68 -0
- package/npm/scripts/postinstall.js +87 -0
- package/npm/scripts/resolve-prerelease-version.js +108 -0
- package/npm/scripts/set-package-version.js +35 -0
- package/npm/scripts/sync-python-version.js +22 -0
- package/npm/scripts/test-wrapper-repair.js +137 -0
- package/npm/scripts/test.js +76 -0
- package/package.json +42 -0
- package/pyproject.toml +36 -0
- package/skills/smart-search-cli/SKILL.md +41 -0
- package/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/skills/smart-search-cli/references/cli-core.md +5 -0
- package/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/skills/smart-search-cli/references/regression-release.md +28 -0
- package/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/__init__.py +1 -0
- package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
- package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/cli.py +3118 -0
- package/src/smart_search/config.py +809 -0
- package/src/smart_search/embedding_presets.py +40 -0
- package/src/smart_search/intent_router.py +757 -0
- package/src/smart_search/logger.py +43 -0
- package/src/smart_search/providers/__init__.py +20 -0
- package/src/smart_search/providers/base.py +41 -0
- package/src/smart_search/providers/context7.py +141 -0
- package/src/smart_search/providers/exa.py +206 -0
- package/src/smart_search/providers/jina.py +136 -0
- package/src/smart_search/providers/openai_compatible.py +541 -0
- package/src/smart_search/providers/xai_responses.py +117 -0
- package/src/smart_search/providers/zhipu.py +143 -0
- package/src/smart_search/providers/zhipu_mcp.py +230 -0
- package/src/smart_search/service.py +3445 -0
- package/src/smart_search/skill_installer.py +342 -0
- package/src/smart_search/sources.py +429 -0
- package/src/smart_search/utils.py +220 -0
|
@@ -0,0 +1,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
|