@dsh-plus/web-search-services 0.1.0 → 0.1.2
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/lib/index.js +22 -7
- package/package.json +3 -2
- package/scripts/README.md +34 -0
- package/scripts/context7.py +259 -0
- package/scripts/env_loader.py +46 -0
- package/scripts/exa.py +251 -0
- package/scripts/fetch.py +938 -0
- package/scripts/fetch_playwright.py +492 -0
- package/scripts/openai_chat.py +570 -0
- package/scripts/search.py +962 -0
- package/scripts/tavily.py +609 -0
- package/src/config.ts +3 -2
- package/src/normalize.ts +16 -6
- package/src/provider.ts +13 -2
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OpenAI-compatible Chat Completions helper.
|
|
3
|
+
|
|
4
|
+
仅通过 OpenAI 兼容的 HTTP Chat Completions 端点执行生成式搜索回答。
|
|
5
|
+
配置只读取 SEARCH_OPENAI_*,避免误用其他工具的 OPENAI_* 环境变量。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from env_loader import load_env_file
|
|
23
|
+
|
|
24
|
+
load_env_file()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServiceError(RuntimeError):
|
|
28
|
+
"""服务调用失败。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, *, status: int | None = None, details: Any = None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status = status
|
|
33
|
+
self.details = details
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MissingAPIKey(ServiceError):
|
|
37
|
+
"""缺少 API Key。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MissingModel(ServiceError):
|
|
41
|
+
"""缺少模型配置。"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
45
|
+
DEFAULT_TIMEOUT = 120.0
|
|
46
|
+
DEFAULT_MAX_ELAPSED = 0.0
|
|
47
|
+
DEFAULT_ANSWER_FORMAT = "markdown"
|
|
48
|
+
SEARCH_PROMPT_TEMPLATE = """你好!想请你帮我查询一些信息。下面是当前时间和协作建议,希望能帮助我们更好地完成搜索:
|
|
49
|
+
|
|
50
|
+
{time_context}
|
|
51
|
+
|
|
52
|
+
1. 关于搜索工具的使用:
|
|
53
|
+
- 如果你目前拥有联网搜索工具,请务必启动搜索功能,先广泛搜索(至少 5 个不同角度或来源线索),再深入整理最相关的 2-3 个方向。
|
|
54
|
+
- 如果你目前无法使用搜索工具,或无法确认你能取得实时信息,请不要勉强回答,也严禁凭借记忆进行猜测、幻想或伪造答案。请直接坦诚地回复:“很抱歉,我目前无法使用搜索工具,无法为您提供最新的实时查询服务。”
|
|
55
|
+
|
|
56
|
+
2. 关于信息的真实性:
|
|
57
|
+
- 请推断用户真实意图;即使问题很简略,也要考虑相关口径、年份、地区、机构和术语差异。
|
|
58
|
+
- 对搜索到的细节(如数据、时间、事件经过、排名、版本等)进行多方验证,不要仅凭单一来源或“直觉/经验”下结论。
|
|
59
|
+
- 优先引用权威来源:官方文档/官网、政府或机构公告、学术论文、可信媒体、维基或行业数据库;中文话题可中英文交叉检索。
|
|
60
|
+
- 每个关键事实、核心数据或结论都应尽量附真实可信的参考来源链接(URL);无法核验时请明确标注不确定性。
|
|
61
|
+
|
|
62
|
+
3. 关于回复的格式:
|
|
63
|
+
- 请使用 {answer_format} 格式,并用清晰的结构化方式呈现回答,例如结论先行、小标题、分点步骤或表格。
|
|
64
|
+
- 技术内容请用通俗语言解释术语;如有公式可用 LaTeX,如有脚本可用代码块。
|
|
65
|
+
{platform_section}
|
|
66
|
+
|
|
67
|
+
我需要你帮我搜索的具体内容是:
|
|
68
|
+
{query}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_THINK_BLOCK_RE = re.compile(
|
|
72
|
+
r"<\s*(?:think|thinking|reasoning)\b[^>]*>.*?<\s*/\s*(?:think|thinking|reasoning)\s*>",
|
|
73
|
+
re.IGNORECASE | re.DOTALL,
|
|
74
|
+
)
|
|
75
|
+
_FENCED_THINK_RE = re.compile(
|
|
76
|
+
r"```\s*(?:think|thinking|reasoning)\s*\n.*?```", re.IGNORECASE | re.DOTALL
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _env(name: str) -> str:
|
|
81
|
+
return os.getenv(name, "").strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _float_env(name: str, default: float) -> float:
|
|
85
|
+
value = _env(name)
|
|
86
|
+
if not value:
|
|
87
|
+
return default
|
|
88
|
+
return float(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bool_env(name: str, default: bool = False) -> bool:
|
|
92
|
+
value = _env(name).lower()
|
|
93
|
+
if not value:
|
|
94
|
+
return default
|
|
95
|
+
return value in {"1", "true", "yes", "on"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_local_time_info() -> str:
|
|
99
|
+
"""返回给搜索提示词使用的本地时间上下文。"""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
local_tz = datetime.now().astimezone().tzinfo
|
|
103
|
+
local_now = datetime.now(local_tz)
|
|
104
|
+
except Exception: # noqa: BLE001 - 时间上下文失败时安全回退 UTC
|
|
105
|
+
local_now = datetime.now(timezone.utc)
|
|
106
|
+
|
|
107
|
+
weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
|
108
|
+
return (
|
|
109
|
+
"[当前时间上下文]\n"
|
|
110
|
+
f"- Date: {local_now.strftime('%Y-%m-%d')} ({weekdays_cn[local_now.weekday()]})\n"
|
|
111
|
+
f"- Time: {local_now.strftime('%H:%M:%S')}\n"
|
|
112
|
+
f"- Timezone: {local_now.tzname() or 'Local'}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _platform_section(platform: str | None = None) -> str:
|
|
117
|
+
value = (platform or "").strip()
|
|
118
|
+
if not value:
|
|
119
|
+
return ""
|
|
120
|
+
return (
|
|
121
|
+
"\n4. 关于搜索平台/来源侧重:\n"
|
|
122
|
+
f" - 请优先关注这些平台、站点或来源类型:{value}\n"
|
|
123
|
+
" - 即使指定了平台,也请在必要时使用其他权威来源交叉验证,不要只凭单一来源下结论。"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _base_url(value: str | None = None) -> str:
|
|
128
|
+
return (value or _env("SEARCH_OPENAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _chat_completions_url(base_url: str) -> str:
|
|
132
|
+
base = base_url.rstrip("/")
|
|
133
|
+
if base.endswith("/chat/completions"):
|
|
134
|
+
return base
|
|
135
|
+
if base.endswith("/v1"):
|
|
136
|
+
return f"{base}/chat/completions"
|
|
137
|
+
return f"{base}/v1/chat/completions"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _api_key() -> str:
|
|
141
|
+
key = _env("SEARCH_OPENAI_API_KEY")
|
|
142
|
+
if not key:
|
|
143
|
+
raise MissingAPIKey("缺少 SEARCH_OPENAI_API_KEY")
|
|
144
|
+
return key
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _model(value: str | None = None) -> str:
|
|
148
|
+
model = (value or _env("SEARCH_OPENAI_MODEL")).strip()
|
|
149
|
+
if not model:
|
|
150
|
+
raise MissingModel(
|
|
151
|
+
"缺少 SEARCH_OPENAI_MODEL 或 --model/--openai-model;OpenAI Chat provider 不设置默认模型"
|
|
152
|
+
)
|
|
153
|
+
return model
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _timeout(value: float | None = None) -> float:
|
|
157
|
+
return float(
|
|
158
|
+
value
|
|
159
|
+
if value is not None
|
|
160
|
+
else _float_env("SEARCH_OPENAI_TIMEOUT", DEFAULT_TIMEOUT)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _max_elapsed(value: float | None = None) -> float:
|
|
165
|
+
return float(
|
|
166
|
+
value
|
|
167
|
+
if value is not None
|
|
168
|
+
else _float_env("SEARCH_OPENAI_MAX_ELAPSED", DEFAULT_MAX_ELAPSED)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _answer_format(value: str | None = None) -> str:
|
|
173
|
+
return (
|
|
174
|
+
(value or _env("SEARCH_OPENAI_ANSWER_FORMAT") or DEFAULT_ANSWER_FORMAT)
|
|
175
|
+
.strip()
|
|
176
|
+
.lower()
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _decode_response(raw: bytes) -> Any:
|
|
181
|
+
text = raw.decode("utf-8", "replace")
|
|
182
|
+
try:
|
|
183
|
+
return json.loads(text)
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _redact_value(value: Any, secret: str) -> Any:
|
|
189
|
+
"""递归脱敏,避免错误详情中意外回显 key。"""
|
|
190
|
+
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
return (
|
|
193
|
+
value.replace(secret, "<redacted-search-openai-key>") if secret else value
|
|
194
|
+
)
|
|
195
|
+
if isinstance(value, list):
|
|
196
|
+
return [_redact_value(item, secret) for item in value]
|
|
197
|
+
if isinstance(value, dict):
|
|
198
|
+
redacted: dict[str, Any] = {}
|
|
199
|
+
for key, item in value.items():
|
|
200
|
+
if str(key).lower() in {"authorization", "api_key", "api-key", "key"}:
|
|
201
|
+
redacted[key] = "<redacted-search-openai-key>"
|
|
202
|
+
else:
|
|
203
|
+
redacted[key] = _redact_value(item, secret)
|
|
204
|
+
return redacted
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _clean_answer(text: str) -> str:
|
|
209
|
+
"""剔除常见显式思考块,并整理空白。"""
|
|
210
|
+
|
|
211
|
+
cleaned = _THINK_BLOCK_RE.sub("", text)
|
|
212
|
+
cleaned = _FENCED_THINK_RE.sub("", cleaned)
|
|
213
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
214
|
+
return cleaned.strip()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_user_prompt(
|
|
218
|
+
query: str,
|
|
219
|
+
*,
|
|
220
|
+
answer_format: str = DEFAULT_ANSWER_FORMAT,
|
|
221
|
+
platform: str | None = None,
|
|
222
|
+
prompt_template: str | None = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""构造搜索提示词。注意:该提示词用于 user message,不伪装为 system。"""
|
|
225
|
+
|
|
226
|
+
template = (
|
|
227
|
+
prompt_template or _env("SEARCH_OPENAI_SEARCH_PROMPT") or SEARCH_PROMPT_TEMPLATE
|
|
228
|
+
)
|
|
229
|
+
return template.format(
|
|
230
|
+
query=query,
|
|
231
|
+
answer_format=answer_format,
|
|
232
|
+
platform=(platform or "").strip(),
|
|
233
|
+
platform_section=_platform_section(platform),
|
|
234
|
+
time_context=get_local_time_info(),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _json_or_none(value: str | None) -> dict[str, Any] | None:
|
|
239
|
+
if not value:
|
|
240
|
+
return None
|
|
241
|
+
return json.loads(value)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _append_delta_content(delta: dict[str, Any], parts: list[str]) -> None:
|
|
245
|
+
"""只收集正式回答内容,显式忽略 reasoning/thinking 字段。"""
|
|
246
|
+
|
|
247
|
+
content = delta.get("content")
|
|
248
|
+
if isinstance(content, str):
|
|
249
|
+
parts.append(content)
|
|
250
|
+
elif isinstance(content, list):
|
|
251
|
+
for item in content:
|
|
252
|
+
if isinstance(item, dict):
|
|
253
|
+
text = item.get("text")
|
|
254
|
+
if isinstance(text, str):
|
|
255
|
+
parts.append(text)
|
|
256
|
+
elif isinstance(item, str):
|
|
257
|
+
parts.append(item)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def collect_stream(
|
|
261
|
+
lines: Iterable[bytes], *, started: float | None = None, max_elapsed: float = 0.0
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
"""收集 SSE 流式响应,返回完整内容与元信息。
|
|
264
|
+
|
|
265
|
+
`max_elapsed <= 0` 表示不设置硬上限;socket timeout 由 urllib 作为空闲超时控制,
|
|
266
|
+
因此只要服务端持续输出 chunk,就会自然延长等待。
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
started_at = time.monotonic() if started is None else started
|
|
270
|
+
content_parts: list[str] = []
|
|
271
|
+
usage: Any = None
|
|
272
|
+
model: str | None = None
|
|
273
|
+
finish_reason: str | None = None
|
|
274
|
+
chunk_count = 0
|
|
275
|
+
ignored_reasoning = False
|
|
276
|
+
parse_errors: list[str] = []
|
|
277
|
+
|
|
278
|
+
for raw_line in lines:
|
|
279
|
+
if max_elapsed > 0 and time.monotonic() - started_at > max_elapsed:
|
|
280
|
+
raise ServiceError(f"OpenAI Chat 流式响应超过硬上限 {max_elapsed:g} 秒")
|
|
281
|
+
line = raw_line.decode("utf-8", "replace").strip()
|
|
282
|
+
if not line or line.startswith(":"):
|
|
283
|
+
continue
|
|
284
|
+
if not line.startswith("data:"):
|
|
285
|
+
continue
|
|
286
|
+
payload = line[5:].strip()
|
|
287
|
+
if payload == "[DONE]":
|
|
288
|
+
break
|
|
289
|
+
try:
|
|
290
|
+
event = json.loads(payload)
|
|
291
|
+
except json.JSONDecodeError as exc:
|
|
292
|
+
parse_errors.append(str(exc))
|
|
293
|
+
continue
|
|
294
|
+
chunk_count += 1
|
|
295
|
+
if usage is None and event.get("usage") is not None:
|
|
296
|
+
usage = event.get("usage")
|
|
297
|
+
if model is None and isinstance(event.get("model"), str):
|
|
298
|
+
model = event.get("model")
|
|
299
|
+
for choice in event.get("choices") or []:
|
|
300
|
+
if not isinstance(choice, dict):
|
|
301
|
+
continue
|
|
302
|
+
if choice.get("finish_reason") is not None:
|
|
303
|
+
finish_reason = choice.get("finish_reason")
|
|
304
|
+
delta = choice.get("delta") or {}
|
|
305
|
+
if isinstance(delta, dict):
|
|
306
|
+
if any(
|
|
307
|
+
key in delta
|
|
308
|
+
for key in (
|
|
309
|
+
"reasoning_content",
|
|
310
|
+
"reasoning",
|
|
311
|
+
"thinking",
|
|
312
|
+
"reasoning_details",
|
|
313
|
+
)
|
|
314
|
+
):
|
|
315
|
+
ignored_reasoning = True
|
|
316
|
+
_append_delta_content(delta, content_parts)
|
|
317
|
+
message = choice.get("message") or {}
|
|
318
|
+
if isinstance(message, dict):
|
|
319
|
+
if any(
|
|
320
|
+
key in message
|
|
321
|
+
for key in (
|
|
322
|
+
"reasoning_content",
|
|
323
|
+
"reasoning",
|
|
324
|
+
"thinking",
|
|
325
|
+
"reasoning_details",
|
|
326
|
+
)
|
|
327
|
+
):
|
|
328
|
+
ignored_reasoning = True
|
|
329
|
+
_append_delta_content(message, content_parts)
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"answer": _clean_answer("".join(content_parts)),
|
|
333
|
+
"model": model,
|
|
334
|
+
"finishReason": finish_reason,
|
|
335
|
+
"usage": usage,
|
|
336
|
+
"chunkCount": chunk_count,
|
|
337
|
+
"ignoredReasoning": ignored_reasoning,
|
|
338
|
+
"parseErrors": parse_errors,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _build_body(
|
|
343
|
+
query: str,
|
|
344
|
+
*,
|
|
345
|
+
model: str,
|
|
346
|
+
answer_format: str,
|
|
347
|
+
platform: str | None = None,
|
|
348
|
+
system_prompt: str | None = None,
|
|
349
|
+
thinking_effort: str | None = None,
|
|
350
|
+
thinking_field: str | None = None,
|
|
351
|
+
max_tokens: int | None = None,
|
|
352
|
+
temperature: float | None = None,
|
|
353
|
+
extra: dict[str, Any] | None = None,
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
messages: list[dict[str, str]] = []
|
|
356
|
+
if system_prompt:
|
|
357
|
+
# 只有用户显式提供 system prompt 时才发送;默认搜索提示词始终放在 user message。
|
|
358
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
359
|
+
messages.append(
|
|
360
|
+
{
|
|
361
|
+
"role": "user",
|
|
362
|
+
"content": build_user_prompt(query, answer_format=answer_format, platform=platform),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
body: dict[str, Any] = {"model": model, "messages": messages, "stream": True}
|
|
367
|
+
effort = thinking_effort or _env("SEARCH_OPENAI_THINKING_EFFORT")
|
|
368
|
+
field = (
|
|
369
|
+
thinking_field
|
|
370
|
+
if thinking_field is not None
|
|
371
|
+
else (_env("SEARCH_OPENAI_THINKING_FIELD") or "reasoning_effort")
|
|
372
|
+
)
|
|
373
|
+
if effort and field:
|
|
374
|
+
body[field] = effort
|
|
375
|
+
if max_tokens is not None:
|
|
376
|
+
body["max_tokens"] = max_tokens
|
|
377
|
+
if temperature is not None:
|
|
378
|
+
body["temperature"] = temperature
|
|
379
|
+
if _bool_env("SEARCH_OPENAI_STREAM_INCLUDE_USAGE", False):
|
|
380
|
+
body["stream_options"] = {"include_usage": True}
|
|
381
|
+
if extra:
|
|
382
|
+
body.update(extra)
|
|
383
|
+
return body
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def request(
|
|
387
|
+
query: str,
|
|
388
|
+
*,
|
|
389
|
+
model: str | None = None,
|
|
390
|
+
base_url: str | None = None,
|
|
391
|
+
answer_format: str | None = None,
|
|
392
|
+
platform: str | None = None,
|
|
393
|
+
system_prompt: str | None = None,
|
|
394
|
+
thinking_effort: str | None = None,
|
|
395
|
+
thinking_field: str | None = None,
|
|
396
|
+
max_tokens: int | None = None,
|
|
397
|
+
temperature: float | None = None,
|
|
398
|
+
timeout: float | None = None,
|
|
399
|
+
max_elapsed: float | None = None,
|
|
400
|
+
extra: dict[str, Any] | None = None,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
key = _api_key()
|
|
403
|
+
resolved_model = _model(model)
|
|
404
|
+
resolved_base_url = _base_url(base_url)
|
|
405
|
+
resolved_answer_format = _answer_format(answer_format)
|
|
406
|
+
resolved_timeout = _timeout(timeout)
|
|
407
|
+
resolved_max_elapsed = _max_elapsed(max_elapsed)
|
|
408
|
+
body = _build_body(
|
|
409
|
+
query,
|
|
410
|
+
model=resolved_model,
|
|
411
|
+
answer_format=resolved_answer_format,
|
|
412
|
+
platform=platform,
|
|
413
|
+
system_prompt=system_prompt,
|
|
414
|
+
thinking_effort=thinking_effort,
|
|
415
|
+
thinking_field=thinking_field,
|
|
416
|
+
max_tokens=max_tokens,
|
|
417
|
+
temperature=temperature,
|
|
418
|
+
extra=extra,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
url = _chat_completions_url(resolved_base_url)
|
|
422
|
+
headers = {
|
|
423
|
+
"Authorization": f"Bearer {key}",
|
|
424
|
+
"Content-Type": "application/json",
|
|
425
|
+
"Accept": "text/event-stream",
|
|
426
|
+
"User-Agent": "pi-search-services-skill/1.0",
|
|
427
|
+
}
|
|
428
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
429
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
430
|
+
started = time.monotonic()
|
|
431
|
+
try:
|
|
432
|
+
with urllib.request.urlopen(req, timeout=resolved_timeout) as resp:
|
|
433
|
+
collected = collect_stream(
|
|
434
|
+
resp, started=started, max_elapsed=resolved_max_elapsed
|
|
435
|
+
)
|
|
436
|
+
if not collected["answer"]:
|
|
437
|
+
raise ServiceError(
|
|
438
|
+
"OpenAI Chat 返回为空",
|
|
439
|
+
status=getattr(resp, "status", None),
|
|
440
|
+
details={
|
|
441
|
+
"chunkCount": collected["chunkCount"],
|
|
442
|
+
"parseErrors": collected["parseErrors"],
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
return {
|
|
446
|
+
"ok": True,
|
|
447
|
+
"service": "openai-chat",
|
|
448
|
+
"status": getattr(resp, "status", None),
|
|
449
|
+
"data": {
|
|
450
|
+
"answer": collected["answer"],
|
|
451
|
+
"answerFormat": resolved_answer_format,
|
|
452
|
+
"model": collected["model"] or resolved_model,
|
|
453
|
+
"finishReason": collected["finishReason"],
|
|
454
|
+
"usage": collected["usage"],
|
|
455
|
+
"stream": True,
|
|
456
|
+
"chunkCount": collected["chunkCount"],
|
|
457
|
+
"ignoredReasoning": collected["ignoredReasoning"],
|
|
458
|
+
"parseErrors": collected["parseErrors"],
|
|
459
|
+
},
|
|
460
|
+
"elapsedSeconds": round(time.monotonic() - started, 3),
|
|
461
|
+
}
|
|
462
|
+
except urllib.error.HTTPError as exc:
|
|
463
|
+
details = _redact_value(_decode_response(exc.read()), key)
|
|
464
|
+
raise ServiceError(
|
|
465
|
+
f"OpenAI Chat HTTP {exc.code}", status=exc.code, details=details
|
|
466
|
+
) from exc
|
|
467
|
+
except urllib.error.URLError as exc:
|
|
468
|
+
raise ServiceError(
|
|
469
|
+
f"OpenAI Chat 网络错误: {exc.reason}", details=_redact_value(str(exc), key)
|
|
470
|
+
) from exc
|
|
471
|
+
except TimeoutError as exc:
|
|
472
|
+
raise ServiceError(
|
|
473
|
+
f"OpenAI Chat 空闲超时: {resolved_timeout:g} 秒内未收到新数据",
|
|
474
|
+
details=str(exc),
|
|
475
|
+
) from exc
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def search(query: str, **kwargs: Any) -> dict[str, Any]:
|
|
479
|
+
"""对外搜索接口,保持输入 query、输出结构化结果。"""
|
|
480
|
+
|
|
481
|
+
return request(query, **kwargs)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _print_json(value: Any) -> None:
|
|
485
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def main(argv: list[str] | None = None) -> int:
|
|
489
|
+
parser = argparse.ArgumentParser(
|
|
490
|
+
description="OpenAI-compatible Chat Completions search helper"
|
|
491
|
+
)
|
|
492
|
+
sub = parser.add_subparsers(dest="operation", required=True)
|
|
493
|
+
|
|
494
|
+
p = sub.add_parser(
|
|
495
|
+
"search", help="通过 OpenAI-compatible Chat Completions 生成搜索式回答"
|
|
496
|
+
)
|
|
497
|
+
p.add_argument("--query", required=True)
|
|
498
|
+
p.add_argument("--model", help="模型名;也可用 SEARCH_OPENAI_MODEL,未配置则失败")
|
|
499
|
+
p.add_argument(
|
|
500
|
+
"--base-url",
|
|
501
|
+
help="OpenAI-compatible base URL;默认 SEARCH_OPENAI_BASE_URL 或 https://api.openai.com/v1",
|
|
502
|
+
)
|
|
503
|
+
p.add_argument(
|
|
504
|
+
"--thinking-effort",
|
|
505
|
+
choices=["low", "medium", "high"],
|
|
506
|
+
help="思考程度;会写入 thinking 字段,字段名默认 reasoning_effort",
|
|
507
|
+
)
|
|
508
|
+
p.add_argument(
|
|
509
|
+
"--thinking-field",
|
|
510
|
+
help="思考程度请求字段名;默认 SEARCH_OPENAI_THINKING_FIELD 或 reasoning_effort;传空字符串可禁用",
|
|
511
|
+
)
|
|
512
|
+
p.add_argument(
|
|
513
|
+
"--answer-format",
|
|
514
|
+
default=None,
|
|
515
|
+
choices=["markdown", "text", "json"],
|
|
516
|
+
help="回答格式;默认 SEARCH_OPENAI_ANSWER_FORMAT 或 markdown",
|
|
517
|
+
)
|
|
518
|
+
p.add_argument("--platform", help="优先关注的平台、站点或来源类型;会写入 user message")
|
|
519
|
+
p.add_argument(
|
|
520
|
+
"--system-prompt",
|
|
521
|
+
help="仅当用户显式需要时才发送的真实 system prompt;默认搜索提示词不会放在 system role",
|
|
522
|
+
)
|
|
523
|
+
p.add_argument("--max-tokens", type=int)
|
|
524
|
+
p.add_argument("--temperature", type=float)
|
|
525
|
+
p.add_argument(
|
|
526
|
+
"--timeout",
|
|
527
|
+
type=float,
|
|
528
|
+
help="连接/空闲超时秒数;持续收到流式 chunk 时会自动延长",
|
|
529
|
+
)
|
|
530
|
+
p.add_argument("--max-elapsed", type=float, help="硬上限秒数;0 表示不设置硬上限")
|
|
531
|
+
p.add_argument("--extra-json", help="合并到请求体的额外 JSON")
|
|
532
|
+
|
|
533
|
+
args = parser.parse_args(argv)
|
|
534
|
+
try:
|
|
535
|
+
if args.operation == "search":
|
|
536
|
+
out = search(
|
|
537
|
+
args.query,
|
|
538
|
+
model=args.model,
|
|
539
|
+
base_url=args.base_url,
|
|
540
|
+
answer_format=args.answer_format,
|
|
541
|
+
platform=args.platform,
|
|
542
|
+
system_prompt=args.system_prompt,
|
|
543
|
+
thinking_effort=args.thinking_effort,
|
|
544
|
+
thinking_field=args.thinking_field,
|
|
545
|
+
max_tokens=args.max_tokens,
|
|
546
|
+
temperature=args.temperature,
|
|
547
|
+
timeout=args.timeout,
|
|
548
|
+
max_elapsed=args.max_elapsed,
|
|
549
|
+
extra=_json_or_none(args.extra_json),
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
parser.error("未知操作")
|
|
553
|
+
return 2
|
|
554
|
+
_print_json(out)
|
|
555
|
+
return 0
|
|
556
|
+
except ServiceError as exc:
|
|
557
|
+
_print_json(
|
|
558
|
+
{
|
|
559
|
+
"ok": False,
|
|
560
|
+
"service": "openai-chat",
|
|
561
|
+
"error": str(exc),
|
|
562
|
+
"status": exc.status,
|
|
563
|
+
"details": exc.details,
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
return 1
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
sys.exit(main())
|