mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-25 17:36:29 +08:00
Merge branch 'feat/web-search-tavily-searxng'
This commit is contained in:
commit
f04f3524e2
7 files changed
+446
-30
No files matched your search
@@ -1,10 +1,12 @@
|
||||
"""Web Search tool. Supports six backends with a unified response format:
|
||||
"""Web Search tool. Supports eight backends with a unified response format:
|
||||
- bocha (https://open.bochaai.com)
|
||||
- zhipu (https://docs.bigmodel.cn/cn/guide/tools/web-search)
|
||||
- qianfan (https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy)
|
||||
- linkai (https://link-ai.tech, fallback)
|
||||
- anysearch (https://anysearch.com)
|
||||
- serply (https://serply.io, Google/Bing SERP API)
|
||||
- tavily (https://tavily.com, AI-optimized search API)
|
||||
- searxng (https://docs.searxng.org, self-hosted meta search engine)
|
||||
|
||||
Provider selection
|
||||
- strategy 'auto' (default): pick the first configured provider in the
|
||||
@@ -42,7 +44,7 @@ DEFAULT_TIMEOUT = 30
|
||||
# zhipu (strong on long-form articles), linkai (cloud aggregator),
|
||||
# anysearch, serply (global Google/Bing SERP, last since it isn't
|
||||
# benchmarked against the Chinese-market providers above).
|
||||
PROVIDER_ORDER = ("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply")
|
||||
PROVIDER_ORDER = ("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply", "tavily", "searxng")
|
||||
|
||||
PROVIDER_LABELS = {
|
||||
"bocha": "Bocha",
|
||||
@@ -51,6 +53,8 @@ PROVIDER_LABELS = {
|
||||
"linkai": "LinkAI",
|
||||
"anysearch": "AnySearch",
|
||||
"serply": "Serply",
|
||||
"tavily": "Tavily",
|
||||
"searxng": "SearXNG",
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +88,9 @@ def _get_api_key(provider: str) -> str:
|
||||
if provider == "serply":
|
||||
key = (_tools_web_search_conf().get("serply_api_key") or "").strip()
|
||||
return key or os.environ.get("SERPLY_API_KEY", "").strip()
|
||||
if provider == "tavily":
|
||||
key = (_tools_web_search_conf().get("tavily_api_key") or "").strip()
|
||||
return key or os.environ.get("TAVILY_API_KEY", "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
@@ -91,15 +98,25 @@ def _anysearch_anonymous_enabled() -> bool:
|
||||
"""Check if AnySearch anonymous mode is enabled via config."""
|
||||
return bool(_tools_web_search_conf().get("anysearch_anonymous"))
|
||||
|
||||
|
||||
def _get_searxng_url() -> str:
|
||||
"""Resolve SearXNG instance URL from config or environment."""
|
||||
url = (_tools_web_search_conf().get("searxng_url") or "").strip()
|
||||
return url or os.environ.get("SEARXNG_URL", "").strip()
|
||||
|
||||
|
||||
def configured_providers() -> List[str]:
|
||||
"""Configured providers in canonical order. anysearch qualifies with a
|
||||
real key OR an explicit anonymous opt-in (still last in fallback order)."""
|
||||
real key OR an explicit anonymous opt-in (still last in fallback order).
|
||||
searxng qualifies when an instance URL is configured."""
|
||||
result = []
|
||||
for p in PROVIDER_ORDER:
|
||||
if _get_api_key(p):
|
||||
result.append(p)
|
||||
elif p == "anysearch" and _anysearch_anonymous_enabled():
|
||||
result.append(p)
|
||||
elif p == "searxng" and _get_searxng_url():
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
def _configured_strategy() -> str:
|
||||
@@ -242,7 +259,7 @@ class WebSearch(BaseTool):
|
||||
return ToolResult.fail(
|
||||
"Error: No search provider configured. "
|
||||
"Configure one of BOCHA_API_KEY / zhipu_ai_api_key / qianfan_api_key / linkai_api_key / "
|
||||
"anysearch_api_key / SERPLY_API_KEY."
|
||||
"anysearch_api_key / SERPLY_API_KEY / TAVILY_API_KEY / SEARXNG_URL."
|
||||
)
|
||||
|
||||
# Always log the routing decision so multi-provider deployments can
|
||||
@@ -268,6 +285,10 @@ class WebSearch(BaseTool):
|
||||
return self._search_anysearch(query, count, freshness, summary)
|
||||
if provider == "serply":
|
||||
return self._search_serply(query, count)
|
||||
if provider == "tavily":
|
||||
return self._search_tavily(query, count)
|
||||
if provider == "searxng":
|
||||
return self._search_searxng(query, count)
|
||||
return ToolResult.fail(f"Error: Unknown provider '{provider}'")
|
||||
except requests.Timeout:
|
||||
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
||||
@@ -630,3 +651,113 @@ class WebSearch(BaseTool):
|
||||
"total": len(results), "count": len(results), "results": results,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tavily
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _search_tavily(self, query: str, count: int) -> ToolResult:
|
||||
api_key = _get_api_key("tavily")
|
||||
url = "https://api.tavily.com/search"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
max_results = max(1, min(int(count or 10), 20))
|
||||
search_depth = (_tools_web_search_conf().get("tavily_search_depth") or "basic").strip().lower()
|
||||
if search_depth not in ("basic", "advanced"):
|
||||
search_depth = "basic"
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"api_key": api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
"search_depth": search_depth,
|
||||
"include_answer": False,
|
||||
}
|
||||
|
||||
logger.debug(f"[WebSearch] tavily: query='{query}', max_results={max_results}, depth={search_depth}")
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
if resp.status_code == 401:
|
||||
return ToolResult.fail("Error: Invalid Tavily API key.")
|
||||
if resp.status_code == 402:
|
||||
return ToolResult.fail("Error: Tavily API quota exhausted. Check usage at https://tavily.com")
|
||||
if resp.status_code == 429:
|
||||
return ToolResult.fail("Error: Tavily API rate limit reached.")
|
||||
if resp.status_code != 200:
|
||||
return ToolResult.fail(f"Error: Tavily API returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
items = data.get("results") or []
|
||||
results = []
|
||||
for it in items:
|
||||
results.append({
|
||||
"title": it.get("title", ""),
|
||||
"url": it.get("url", ""),
|
||||
"snippet": it.get("content") or it.get("snippet", ""),
|
||||
"siteName": it.get("source") or "",
|
||||
"score": it.get("score"),
|
||||
})
|
||||
return ToolResult.success({
|
||||
"query": query, "backend": "tavily",
|
||||
"total": len(results), "count": len(results), "results": results,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SearXNG (self-hosted meta search engine)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _search_searxng(self, query: str, count: int) -> ToolResult:
|
||||
base_url = _get_searxng_url().rstrip("/")
|
||||
if not base_url:
|
||||
return ToolResult.fail("Error: SearXNG instance URL not configured. Set tools.web_search.searxng_url or SEARXNG_URL.")
|
||||
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"pageno": 1,
|
||||
}
|
||||
language = (_tools_web_search_conf().get("searxng_language") or "").strip()
|
||||
if language:
|
||||
params["language"] = language
|
||||
categories = (_tools_web_search_conf().get("searxng_categories") or "general").strip()
|
||||
if categories:
|
||||
params["categories"] = categories
|
||||
|
||||
url = f"{base_url}/search?{urlencode(params)}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "CowAgent",
|
||||
}
|
||||
|
||||
logger.debug(f"[WebSearch] searxng: query='{query}', instance={base_url}")
|
||||
resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
|
||||
|
||||
if resp.status_code == 401:
|
||||
return ToolResult.fail("Error: SearXNG instance requires authentication.")
|
||||
if resp.status_code == 403:
|
||||
return ToolResult.fail("Error: SearXNG instance access forbidden. Check instance CORS/API settings.")
|
||||
if resp.status_code == 429:
|
||||
return ToolResult.fail("Error: SearXNG rate limit reached.")
|
||||
if resp.status_code != 200:
|
||||
return ToolResult.fail(f"Error: SearXNG returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except (ValueError, TypeError):
|
||||
return ToolResult.fail("Error: SearXNG returned non-JSON response. Ensure the instance supports format=json.")
|
||||
|
||||
items = data.get("results") or []
|
||||
results = []
|
||||
for it in items[:max(1, min(int(count or 10), 50))]:
|
||||
results.append({
|
||||
"title": it.get("title", ""),
|
||||
"url": it.get("url", ""),
|
||||
"snippet": it.get("content") or it.get("snippet", ""),
|
||||
"siteName": it.get("engine") or it.get("source") or "",
|
||||
"score": it.get("score"),
|
||||
})
|
||||
return ToolResult.success({
|
||||
"query": query, "backend": "searxng",
|
||||
"total": len(results), "count": len(results), "results": results,
|
||||
})
|
||||
@@ -157,6 +157,10 @@ const I18N = {
|
||||
models_search_anysearch_desc: '前往 anysearch.com 控制台创建 API Key。',
|
||||
models_search_serply_title: '配置 Serply API Key',
|
||||
models_search_serply_desc: '前往 serply.io 控制台创建 API Key。',
|
||||
models_search_tavily_title: '配置 Tavily API Key',
|
||||
models_search_tavily_desc: '前往 tavily.com 控制台创建 API Key。',
|
||||
models_search_searxng_title: '配置 SearXNG 实例 URL',
|
||||
models_search_searxng_desc: '输入自托管 SearXNG 实例的 URL(无需 API Key)。',
|
||||
models_search_anysearch_anon_hint: '留空可启用匿名模式(无需 API Key)',
|
||||
models_search_anonymous_badge: '匿名',
|
||||
models_search_anonymous_disable: '停用匿名',
|
||||
@@ -645,6 +649,10 @@ const I18N = {
|
||||
models_search_anysearch_desc: '前往 anysearch.com 控制台建立 API Key',
|
||||
models_search_serply_title: '設定 Serply API Key',
|
||||
models_search_serply_desc: '前往 serply.io 控制台建立 API Key',
|
||||
models_search_tavily_title: '設定 Tavily API Key',
|
||||
models_search_tavily_desc: '前往 tavily.com 控制台建立 API Key',
|
||||
models_search_searxng_title: '設定 SearXNG 實例 URL',
|
||||
models_search_searxng_desc: '輸入自託管 SearXNG 實例的 URL(無需 API Key)。',
|
||||
models_search_anysearch_anon_hint: '留空可啟用匿名模式(無需 API Key)',
|
||||
models_search_anonymous_badge: '匿名',
|
||||
models_search_anonymous_disable: '停用匿名',
|
||||
@@ -1128,6 +1136,10 @@ const I18N = {
|
||||
models_search_anysearch_desc: 'Create a key at the AnySearch console (anysearch.com).',
|
||||
models_search_serply_title: 'Configure Serply API Key',
|
||||
models_search_serply_desc: 'Create a key at the Serply console (serply.io).',
|
||||
models_search_tavily_title: 'Configure Tavily API Key',
|
||||
models_search_tavily_desc: 'Create a key at the Tavily console (tavily.com).',
|
||||
models_search_searxng_title: 'Configure SearXNG Instance URL',
|
||||
models_search_searxng_desc: 'Enter the URL of your self-hosted SearXNG instance (no API key required).',
|
||||
models_search_anysearch_anon_hint: 'Leave empty to enable anonymous mode (no API key required)',
|
||||
models_search_anonymous_badge: 'anonymous',
|
||||
models_search_anonymous_disable: 'Disable anonymous',
|
||||
@@ -10959,7 +10971,10 @@ function openSearchAddProviderPicker(missingProviders) {
|
||||
}
|
||||
|
||||
function _launchSearchProviderConfig(providerId, providerMeta) {
|
||||
if (providerId === 'bocha' || providerId === 'anysearch' || providerId === 'serply') {
|
||||
// Providers that hold their own credential (dedicated key or, for SearXNG,
|
||||
// an instance URL) use the bespoke search-key modal. zhipu/qianfan/linkai
|
||||
// reuse a model-vendor key and go through the vendor modal instead.
|
||||
if (['bocha', 'anysearch', 'serply', 'tavily', 'searxng'].includes(providerId)) {
|
||||
openSearchKeyModal(providerId, providerMeta);
|
||||
} else {
|
||||
openVendorModal(providerId, () => loadModelsView({ preserveScroll: true }));
|
||||
@@ -11007,8 +11022,17 @@ function openSearchKeyModal(providerId, providerMeta) {
|
||||
|
||||
const searchCap = (modelsState && modelsState.capabilities && modelsState.capabilities.search) || {};
|
||||
const prov = (searchCap.providers || []).find(p => p.id === providerId);
|
||||
let masked = (providerMeta && providerMeta.api_key_masked) || '';
|
||||
if (!masked && prov && prov.api_key_masked) masked = prov.api_key_masked;
|
||||
const isSearxng = providerId === 'searxng';
|
||||
// SearXNG holds an instance URL (echoed back verbatim in url_masked); the
|
||||
// rest hold a masked API key. Resolve whichever applies as the field value.
|
||||
let masked;
|
||||
if (isSearxng) {
|
||||
masked = (providerMeta && providerMeta.url_masked) || (prov && prov.url_masked) || '';
|
||||
} else {
|
||||
masked = (providerMeta && providerMeta.api_key_masked) || '';
|
||||
if (!masked && prov && prov.api_key_masked) masked = prov.api_key_masked;
|
||||
}
|
||||
// SearXNG URL is not masked, so it's safe to keep editable (not a sentinel).
|
||||
const hasKey = !!masked;
|
||||
const isAnonymous = providerId === 'anysearch' && !!((providerMeta && providerMeta.anonymous) || (prov && prov.anonymous));
|
||||
const clearBtnHtml = (hasKey || isAnonymous)
|
||||
@@ -11035,14 +11059,14 @@ function openSearchKeyModal(providerId, providerMeta) {
|
||||
w-full max-w-md mx-4 p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_' + providerId + '_title')}</h3>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${descText}</p>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${providerId === 'searxng' ? 'Instance URL' : 'API Key'}</label>
|
||||
<input id="search-key-input" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono ${hasKey ? 'cfg-key-masked' : ''}"
|
||||
focus:outline-none focus:border-primary-500 ${isSearxng ? '' : 'font-mono'} ${(hasKey && !isSearxng) ? 'cfg-key-masked' : ''}"
|
||||
value="${escapeHtml(masked)}"
|
||||
data-masked="${hasKey ? '1' : ''}"
|
||||
placeholder="sk-..." />
|
||||
data-masked="${(hasKey && !isSearxng) ? '1' : ''}"
|
||||
placeholder="${isSearxng ? 'https://searxng.example.com' : 'sk-...'}" />
|
||||
<div class="flex items-center justify-between gap-3 mt-5">
|
||||
<div>${clearBtnHtml}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -11126,6 +11150,31 @@ function _saveSearchKey(providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerId === 'searxng') {
|
||||
// SearXNG uses an instance URL, not an API key. Empty input is a no-op
|
||||
// here (use the clear button to remove it).
|
||||
if (!apiKey) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'set_search_credential',
|
||||
provider: providerId,
|
||||
url: apiKey, // reuse the input value as the URL
|
||||
}),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') {
|
||||
const modal = document.getElementById('search-key-modal');
|
||||
if (modal) modal.remove();
|
||||
loadModelsView({ preserveScroll: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
input.focus();
|
||||
return;
|
||||
@@ -11144,10 +11193,14 @@ function _saveSearchKey(providerId) {
|
||||
}
|
||||
|
||||
function _clearSearchKey(providerId) {
|
||||
// SearXNG is cleared by emptying its instance URL, not an API key.
|
||||
const payload = (providerId === 'searxng')
|
||||
? { action: 'set_search_credential', provider: providerId, url: '' }
|
||||
: { action: 'set_search_credential', provider: providerId, api_key: '' };
|
||||
fetch('/api/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_search_credential', provider: providerId, api_key: '' }),
|
||||
body: JSON.stringify(payload),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') {
|
||||
const modal = document.getElementById('search-key-modal');
|
||||
|
||||
@@ -4228,7 +4228,7 @@ class ModelsHandler:
|
||||
|
||||
# Canonical search provider order. Mirrors PROVIDER_ORDER in
|
||||
# agent/tools/web_search/web_search.py — keep them in sync.
|
||||
_SEARCH_PROVIDERS = ("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply")
|
||||
_SEARCH_PROVIDERS = ("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply", "tavily", "searxng")
|
||||
|
||||
_SEARCH_PROVIDER_LABELS = {
|
||||
"bocha": {"zh": "博查", "en": "Bocha"},
|
||||
@@ -4237,6 +4237,8 @@ class ModelsHandler:
|
||||
"linkai": {"zh": "LinkAI", "en": "LinkAI"},
|
||||
"anysearch": {"zh": "AnySearch", "en": "AnySearch"},
|
||||
"serply": {"zh": "Serply", "en": "Serply"},
|
||||
"tavily": {"zh": "Tavily", "en": "Tavily"},
|
||||
"searxng": {"zh": "SearXNG", "en": "SearXNG"},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -4262,6 +4264,12 @@ class ModelsHandler:
|
||||
block = tools_cfg.get("web_search") or {} if isinstance(tools_cfg, dict) else {}
|
||||
return (block.get("serply_api_key") if isinstance(block, dict) else "") or os.environ.get(
|
||||
"SERPLY_API_KEY", "")
|
||||
if provider == "tavily":
|
||||
tools_cfg = local_config.get("tools") or {}
|
||||
block = tools_cfg.get("web_search") or {} if isinstance(tools_cfg, dict) else {}
|
||||
return (block.get("tavily_api_key") if isinstance(block, dict) else "") or os.environ.get(
|
||||
"TAVILY_API_KEY", "")
|
||||
# searxng uses an instance URL, not an API key — handled in _search_capability
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
@@ -4276,6 +4284,7 @@ class ModelsHandler:
|
||||
ws_cfg = {}
|
||||
|
||||
anonymous_on = bool(ws_cfg.get("anysearch_anonymous"))
|
||||
searxng_url = (ws_cfg.get("searxng_url") or "").strip() if isinstance(ws_cfg, dict) else ""
|
||||
providers = []
|
||||
configured_ids = []
|
||||
for pid in cls._SEARCH_PROVIDERS:
|
||||
@@ -4283,6 +4292,9 @@ class ModelsHandler:
|
||||
if pid == "anysearch":
|
||||
# AnySearch: real key, or an explicit anonymous opt-in.
|
||||
ok = cls._is_real_key(raw_key) or anonymous_on
|
||||
elif pid == "searxng":
|
||||
# SearXNG: self-hosted, no auth — available when instance URL is set.
|
||||
ok = bool(searxng_url)
|
||||
else:
|
||||
ok = cls._is_real_key(raw_key)
|
||||
providers.append({
|
||||
@@ -4294,7 +4306,11 @@ class ModelsHandler:
|
||||
# this hint to decide which credential editor to surface.
|
||||
# Lets the frontend badge "匿名/anonymous" only in anonymous mode.
|
||||
"anonymous": pid == "anysearch" and ok and not raw_key,
|
||||
"needs_dedicated_key": pid in ("bocha", "anysearch", "serply"),
|
||||
"needs_dedicated_key": pid in ("bocha", "anysearch", "serply", "tavily"),
|
||||
"needs_url": pid == "searxng",
|
||||
# SearXNG stores an instance URL (not a secret), so echo it back
|
||||
# verbatim to prefill/edit; other providers mask their key.
|
||||
"url_masked": searxng_url if pid == "searxng" else "",
|
||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if raw_key else "",
|
||||
})
|
||||
if ok:
|
||||
@@ -5241,7 +5257,7 @@ class ModelsHandler:
|
||||
reuse model-vendor credentials and go through set_provider instead.
|
||||
"""
|
||||
provider = (data.get("provider") or "bocha").strip().lower()
|
||||
if provider not in ("bocha", "anysearch", "serply"):
|
||||
if provider not in ("bocha", "anysearch", "serply", "tavily", "searxng"):
|
||||
return json.dumps({"status": "error", "message": f"unsupported search provider: {provider!r}"})
|
||||
|
||||
if provider == "anysearch":
|
||||
@@ -5264,6 +5280,16 @@ class ModelsHandler:
|
||||
logger.info(
|
||||
f"[ModelsHandler] search credential set: anysearch_api_key={'***' if api_key else ''}, anonymous={bool(anonymous and not api_key)}")
|
||||
return json.dumps({"status": "success", "provider": provider})
|
||||
if provider == "searxng":
|
||||
# SearXNG uses an instance URL, not an API key.
|
||||
instance_url = (data.get("url") or "").strip() if isinstance(data.get("url"), str) else ""
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
self._set_nested_namespace_value(local_config, "tools", "web_search", "searxng_url", instance_url)
|
||||
self._set_nested_namespace_value(file_cfg, "tools", "web_search", "searxng_url", instance_url)
|
||||
self._write_file_config(file_cfg)
|
||||
logger.info(f"[ModelsHandler] search credential set: searxng_url={'***' if instance_url else ''}")
|
||||
return json.dumps({"status": "success", "provider": provider})
|
||||
else:
|
||||
key_field = f"{provider}_api_key"
|
||||
api_key = (data.get("api_key") or "").strip() if isinstance(data.get("api_key"), str) else ""
|
||||
|
||||
@@ -3,7 +3,7 @@ title: web_search - Web 検索
|
||||
description: インターネットからリアルタイム情報を検索。複数の検索プロバイダーに対応
|
||||
---
|
||||
|
||||
インターネットからリアルタイム情報、ニュース、リサーチなどを検索します。Bocha、百度 Qianfan、智谱(Zhipu)、LinkAI、AnySearch、Serply の 6 つのバックエンドに対応しており、いずれか 1 社を設定すれば利用可能です。
|
||||
インターネットからリアルタイム情報、ニュース、リサーチなどを検索します。Bocha、百度 Qianfan、智谱(Zhipu)、LinkAI、AnySearch、Serply、Tavily、SearXNG の 8 つのバックエンドに対応しており、いずれか 1 社を設定すれば利用可能です。
|
||||
|
||||
<Tip>
|
||||
[Web コンソール](/ja/channels/web) の「モデル管理 → 検索」パネルから、プロバイダーと戦略を可視化して設定するのが推奨です。設定ファイルを手動で編集する必要はありません。
|
||||
@@ -19,8 +19,10 @@ description: インターネットからリアルタイム情報を検索。複
|
||||
| LinkAI | `linkai_api_key` を再利用 | [LinkAI コンソール](https://link-ai.tech/console/interface) |
|
||||
| AnySearch | オプション設定 `tools.web_search.anysearch_api_key`(匿名モード対応) | [AnySearch Open Platform](https://anysearch.com/) |
|
||||
| Serply | `tools.web_search.serply_api_key` | [Serply](https://serply.io)([ドキュメント](https://serply.io/docs)) |
|
||||
| Tavily | `tools.web_search.tavily_api_key` | [Tavily](https://tavily.com) |
|
||||
| SearXNG | `tools.web_search.searxng_url`(セルフホスト型インスタンス URL、API Key 不要) | [SearXNG](https://docs.searxng.org/) |
|
||||
|
||||
Bocha、AnySearch、Serplyについては専用キー(bocha_api_key / anysearch_api_key / serply_api_key)を別途設定する必要がありますが、その他の3社は対応するモデルのAPIキーをそのまま流用でき、モデルを設定すれば同時に検索機能も利用可能になります。
|
||||
Bocha、AnySearch、Serply、Tavilyについては専用キーを別途設定する必要がありますが、その他の3社は対応するモデルのAPIキーをそのまま流用でき、モデルを設定すれば同時に検索機能も利用可能になります。SearXNGはセルフホスト型で、インスタンスURLのみ設定が必要です。
|
||||
|
||||
## ルーティング戦略
|
||||
|
||||
@@ -35,7 +37,7 @@ Bocha、AnySearch、Serplyについては専用キー(bocha_api_key / anysearc
|
||||
}
|
||||
```
|
||||
|
||||
- `auto`(デフォルト):Agent が設定済みのプロバイダーから自動的に選択し、1 回のタスク内で複数回呼び出し、異なるプロバイダーを切り替えてより包括的な結果を取得できます。未指定の場合は `bocha → qianfan → zhipu → linkai → anysearch → serply` の順でフォールバックします。
|
||||
- `auto`(デフォルト):Agent が設定済みのプロバイダーから自動的に選択し、1 回のタスク内で複数回呼び出し、異なるプロバイダーを切り替えてより包括的な結果を取得できます。未指定の場合は `bocha → qianfan → zhipu → linkai → anysearch → serply → tavily → searxng` の順でフォールバックします。
|
||||
|
||||
- `fixed`:`provider` で指定したプロバイダーに固定。該当プロバイダーの認証情報が欠けている場合は自動的に auto の順序にフォールバックします。
|
||||
|
||||
@@ -50,5 +52,6 @@ Bocha、AnySearch、Serplyについては専用キー(bocha_api_key / anysearc
|
||||
| `provider` | string | いいえ | `auto` 戦略で複数プロバイダーを設定している場合に表示。単回のプロバイダー切り替えに使用 |
|
||||
|
||||
<Note>
|
||||
6 社の認証情報がいずれも未設定の場合、このツールは Agent に登録されません。
|
||||
8 社の認証情報がいずれも未設定の場合、このツールは Agent に登録されません。
|
||||
</Note>
|
||||
|
||||
@@ -3,7 +3,7 @@ title: web_search - Web Search
|
||||
description: Search the internet for real-time information, with support for multiple search providers
|
||||
---
|
||||
|
||||
Search the internet for real-time information, news, research, and more. Supports six backends — Bocha, ERNIE, GLM, LinkAI, AnySearch, and Serply — and works once any one of them is configured.
|
||||
Search the internet for real-time information, news, research, and more. Supports eight backends — Bocha, ERNIE, GLM, LinkAI, AnySearch, Serply, Tavily, and SearXNG — and works once any one of them is configured.
|
||||
|
||||
<Tip>
|
||||
It is recommended to configure providers and routing strategy visually from the "Model Management → Search" panel in the [Web console](/channels/web), without manually editing the configuration file.
|
||||
@@ -19,8 +19,10 @@ Search the internet for real-time information, news, research, and more. Support
|
||||
| LinkAI | Reuses `linkai_api_key` | [LinkAI Console](https://link-ai.tech/console/interface) |
|
||||
| AnySearch | Optional `tools.web_search.anysearch_api_key`(Anonymous mode is available) | [AnySearch Console](https://anysearch.com/) |
|
||||
| Serply | `tools.web_search.serply_api_key` | [Serply](https://serply.io) ([docs](https://serply.io/docs)) |
|
||||
| Tavily | `tools.web_search.tavily_api_key` | [Tavily](https://tavily.com) |
|
||||
| SearXNG | `tools.web_search.searxng_url` (self-hosted instance URL, no API key needed) | [SearXNG](https://docs.searxng.org/) |
|
||||
|
||||
Except for Bocha, AnySearch, and Serply which require dedicated keys (bocha_api_key / anysearch_api_key / serply_api_key), the other three reuse the corresponding model's API key — configuring the model automatically grants search capability.
|
||||
Except for Bocha, AnySearch, Serply, and Tavily which require dedicated keys, the other three reuse the corresponding model's API key — configuring the model automatically grants search capability. SearXNG is self-hosted and only requires an instance URL.
|
||||
|
||||
## Routing Strategy
|
||||
|
||||
@@ -35,7 +37,7 @@ Except for Bocha, AnySearch, and Serply which require dedicated keys (bocha_api_
|
||||
}
|
||||
```
|
||||
|
||||
- `auto` (default): the Agent intelligently picks among configured providers and may call multiple providers in a single task to gather more comprehensive results; when none is specified, falls back through `bocha → qianfan → zhipu → linkai → anysearch → serply`.
|
||||
- `auto` (default): the Agent intelligently picks among configured providers and may call multiple providers in a single task to gather more comprehensive results; when none is specified, falls back through `bocha → qianfan → zhipu → linkai → anysearch → serply → tavily → searxng`.
|
||||
|
||||
- `fixed`: always use the provider specified in `provider`; falls back to the auto order if that provider's credentials are missing.
|
||||
|
||||
@@ -50,5 +52,6 @@ Except for Bocha, AnySearch, and Serply which require dedicated keys (bocha_api_
|
||||
| `provider` | string | No | Available when multiple providers are configured under the `auto` strategy; used to switch provider for a single call |
|
||||
|
||||
<Note>
|
||||
If none of the six credentials are configured, this tool is not registered with the Agent.
|
||||
If none of the eight credentials are configured, this tool is not registered with the Agent.
|
||||
</Note>
|
||||
|
||||
@@ -3,7 +3,7 @@ title: web_search - 联网搜索
|
||||
description: 搜索互联网获取实时信息,支持多个搜索厂商
|
||||
---
|
||||
|
||||
搜索互联网获取实时信息、新闻、研究等内容。支持博查、百度千帆、智谱、LinkAI、AnySearch、Serply 六个后端,配置任意一家即可使用。
|
||||
搜索互联网获取实时信息、新闻、研究等内容。支持博查、百度千帆、智谱、LinkAI、AnySearch、Serply、Tavily、SearXNG 八个后端,配置任意一家即可使用。
|
||||
|
||||
<Tip>
|
||||
推荐通过 [Web 控制台](/zh/channels/web) 的「模型管理 → 搜索」面板可视化配置厂商与策略,无需手动编辑配置文件。
|
||||
@@ -19,8 +19,10 @@ description: 搜索互联网获取实时信息,支持多个搜索厂商
|
||||
| LinkAI | 复用 `linkai_api_key` | [LinkAI 控制台](https://link-ai.tech/console/interface) |
|
||||
| AnySearch | 可选配置项 `tools.web_search.anysearch_api_key`(支持匿名模式) | [AnySearch 控制台](https://anysearch.com/) |
|
||||
| Serply | `tools.web_search.serply_api_key` | [Serply](https://serply.io)([文档](https://serply.io/docs)) |
|
||||
| Tavily | `tools.web_search.tavily_api_key` | [Tavily](https://tavily.com) |
|
||||
| SearXNG | `tools.web_search.searxng_url`(自托管实例 URL,无需 API Key) | [SearXNG](https://docs.searxng.org/) |
|
||||
|
||||
除博查、AnySearch、Serply 需要单独配置专用密钥(bocha_api_key / anysearch_api_key / serply_api_key)外,其他三家直接复用对应模型的 API Key,配好模型即同时获得搜索能力。
|
||||
除博查、AnySearch、Serply、Tavily 需要单独配置专用密钥外,其他三家直接复用对应模型的 API Key,配好模型即同时获得搜索能力。SearXNG 为自托管方案,只需配置实例 URL。
|
||||
|
||||
## 路由策略
|
||||
|
||||
@@ -35,7 +37,7 @@ description: 搜索互联网获取实时信息,支持多个搜索厂商
|
||||
}
|
||||
```
|
||||
|
||||
- `auto`(默认):由 Agent 在已配置的厂商中智能选择,并可在一次任务中多次调用、切换不同厂商以获取更全面的结果;未指定时按 `bocha → qianfan → zhipu → linkai → anysearch → serply` 顺序兜底。
|
||||
- `auto`(默认):由 Agent 在已配置的厂商中智能选择,并可在一次任务中多次调用、切换不同厂商以获取更全面的结果;未指定时按 `bocha → qianfan → zhipu → linkai → anysearch → serply → tavily → searxng` 顺序兜底。
|
||||
|
||||
- `fixed`:固定使用 `provider` 指定的厂商;该厂商凭证缺失时自动回落到 auto 顺序。
|
||||
|
||||
@@ -50,5 +52,6 @@ description: 搜索互联网获取实时信息,支持多个搜索厂商
|
||||
| `provider` | string | 否 | `auto` 策略下配置了多个厂商时可见,用于单次切换厂商 |
|
||||
|
||||
<Note>
|
||||
六家凭证均未配置时,该工具不会注册到 Agent。
|
||||
八家凭证均未配置时,该工具不会注册到 Agent。
|
||||
</Note>
|
||||
|
||||
+200
-3
@@ -77,13 +77,13 @@ class TestAnySearchKeyResolution(unittest.TestCase):
|
||||
|
||||
|
||||
class TestProviderOrder(unittest.TestCase):
|
||||
"""New providers are appended after the four originals, leaving existing
|
||||
routing untouched: anysearch first, then serply."""
|
||||
"""New providers are appended after the originals, leaving existing routing
|
||||
untouched: anysearch, serply, then tavily and searxng last."""
|
||||
|
||||
def test_provider_order_appends_new_providers_last(self):
|
||||
self.assertEqual(
|
||||
web_search_module.PROVIDER_ORDER,
|
||||
("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply"),
|
||||
("bocha", "qianfan", "zhipu", "linkai", "anysearch", "serply", "tavily", "searxng"),
|
||||
)
|
||||
|
||||
def test_web_console_provider_list_stays_in_sync(self):
|
||||
@@ -318,5 +318,202 @@ class TestSerplyBackend(unittest.TestCase):
|
||||
self.assertIn(fragment, result.result)
|
||||
|
||||
|
||||
class TestTavilyKeyResolution(unittest.TestCase):
|
||||
"""The tavily key lives under tools.web_search, falling back to env."""
|
||||
|
||||
def setUp(self):
|
||||
self._prev_env = os.environ.get("TAVILY_API_KEY")
|
||||
os.environ.pop("TAVILY_API_KEY", None)
|
||||
|
||||
def tearDown(self):
|
||||
if self._prev_env is None:
|
||||
os.environ.pop("TAVILY_API_KEY", None)
|
||||
else:
|
||||
os.environ["TAVILY_API_KEY"] = self._prev_env
|
||||
|
||||
def test_tavily_key_from_tools_config(self):
|
||||
"""tools.web_search.tavily_api_key is resolved, and wins over env."""
|
||||
cfg = {"tools": {"web_search": {"tavily_api_key": "test-key-123"}}}
|
||||
with patch.object(web_search_module, "conf", lambda: cfg):
|
||||
self.assertEqual(web_search_module._get_api_key("tavily"), "test-key-123")
|
||||
with patch.dict(os.environ, {"TAVILY_API_KEY": "env-key-456"}):
|
||||
self.assertEqual(web_search_module._get_api_key("tavily"), "test-key-123")
|
||||
|
||||
def test_tavily_key_env_fallback(self):
|
||||
"""Without a config value, TAVILY_API_KEY is used; both empty -> ''."""
|
||||
with patch.object(web_search_module, "conf", lambda: {}):
|
||||
with patch.dict(os.environ, {"TAVILY_API_KEY": "env-key-456"}):
|
||||
self.assertEqual(web_search_module._get_api_key("tavily"), "env-key-456")
|
||||
self.assertEqual(web_search_module._get_api_key("tavily"), "")
|
||||
|
||||
|
||||
class TestTavilyBackend(unittest.TestCase):
|
||||
"""Behaviour of WebSearch._search_tavily with a stubbed HTTP layer."""
|
||||
|
||||
def setUp(self):
|
||||
self.tool = web_search_module.WebSearch()
|
||||
|
||||
def test_search_tavily_maps_results(self):
|
||||
"""Tavily's content/source/score map to the unified output shape, and
|
||||
the query travels in the POST json body alongside the api_key."""
|
||||
payload = {
|
||||
"results": [
|
||||
{"title": "T1", "url": "https://a.example/1", "content": "c1",
|
||||
"source": "a.example", "score": 0.9},
|
||||
{"title": "T2", "url": "https://a.example/2"},
|
||||
]
|
||||
}
|
||||
with patch.object(web_search_module, "_get_api_key", return_value="test-key-123"), \
|
||||
patch.object(web_search_module.requests, "post",
|
||||
return_value=_fake_response(200, payload)) as mock_post:
|
||||
result = self.tool._search_tavily("cowagent", 10)
|
||||
|
||||
self.assertEqual(result.status, "success")
|
||||
self.assertEqual(result.result["backend"], "tavily")
|
||||
self.assertEqual(result.result["total"], 2)
|
||||
first, second = result.result["results"]
|
||||
self.assertEqual(first["title"], "T1")
|
||||
self.assertEqual(first["url"], "https://a.example/1")
|
||||
self.assertEqual(first["snippet"], "c1")
|
||||
self.assertEqual(first["siteName"], "a.example")
|
||||
self.assertEqual(second["snippet"], "")
|
||||
body = mock_post.call_args[1]["json"]
|
||||
self.assertEqual(body["query"], "cowagent")
|
||||
self.assertEqual(body["api_key"], "test-key-123")
|
||||
|
||||
def test_search_tavily_clamps_count(self):
|
||||
"""count is clamped into 1-20 and sent as max_results; falsy -> 10."""
|
||||
with patch.object(web_search_module, "_get_api_key", return_value="k"), \
|
||||
patch.object(web_search_module.requests, "post",
|
||||
return_value=_fake_response(200, {"results": []})) as mock_post:
|
||||
for count, expected in ((20, 20), (99, 20), (0, 10), (None, 10), (5, 5)):
|
||||
with self.subTest(count=count):
|
||||
self.tool._search_tavily("q", count)
|
||||
self.assertEqual(mock_post.call_args[1]["json"]["max_results"], expected)
|
||||
|
||||
def test_search_tavily_error_status_mapping(self):
|
||||
"""401/402/429 map to specific messages; other statuses are generic."""
|
||||
cases = {
|
||||
401: "Invalid Tavily API key",
|
||||
402: "quota exhausted",
|
||||
429: "rate limit reached",
|
||||
500: "HTTP 500",
|
||||
}
|
||||
for status_code, fragment in cases.items():
|
||||
with self.subTest(status_code=status_code):
|
||||
with patch.object(web_search_module, "_get_api_key", return_value="k"), \
|
||||
patch.object(web_search_module.requests, "post",
|
||||
return_value=_fake_response(status_code)):
|
||||
result = self.tool._search_tavily("q", 10)
|
||||
self.assertEqual(result.status, "error")
|
||||
self.assertIn(fragment, result.result)
|
||||
|
||||
|
||||
class TestSearxngUrlResolution(unittest.TestCase):
|
||||
"""SearXNG resolves an instance URL (not an API key) from config or env."""
|
||||
|
||||
def setUp(self):
|
||||
self._prev_env = os.environ.get("SEARXNG_URL")
|
||||
os.environ.pop("SEARXNG_URL", None)
|
||||
|
||||
def tearDown(self):
|
||||
if self._prev_env is None:
|
||||
os.environ.pop("SEARXNG_URL", None)
|
||||
else:
|
||||
os.environ["SEARXNG_URL"] = self._prev_env
|
||||
|
||||
def test_searxng_url_from_tools_config(self):
|
||||
"""tools.web_search.searxng_url is resolved, and wins over env."""
|
||||
cfg = {"tools": {"web_search": {"searxng_url": "https://searx.example"}}}
|
||||
with patch.object(web_search_module, "conf", lambda: cfg):
|
||||
self.assertEqual(web_search_module._get_searxng_url(), "https://searx.example")
|
||||
with patch.dict(os.environ, {"SEARXNG_URL": "https://env.example"}):
|
||||
self.assertEqual(web_search_module._get_searxng_url(), "https://searx.example")
|
||||
|
||||
def test_searxng_url_env_fallback(self):
|
||||
"""Without a config value, SEARXNG_URL is used; both empty -> ''."""
|
||||
with patch.object(web_search_module, "conf", lambda: {}):
|
||||
with patch.dict(os.environ, {"SEARXNG_URL": "https://env.example"}):
|
||||
self.assertEqual(web_search_module._get_searxng_url(), "https://env.example")
|
||||
self.assertEqual(web_search_module._get_searxng_url(), "")
|
||||
|
||||
def test_searxng_qualifies_when_url_configured(self):
|
||||
"""configured_providers() includes searxng once its URL is set."""
|
||||
cfg = {"tools": {"web_search": {"searxng_url": "https://searx.example"}}}
|
||||
with patch.object(web_search_module, "conf", lambda: cfg):
|
||||
self.assertIn("searxng", web_search_module.configured_providers())
|
||||
with patch.object(web_search_module, "conf", lambda: {}):
|
||||
self.assertNotIn("searxng", web_search_module.configured_providers())
|
||||
|
||||
|
||||
class TestSearxngBackend(unittest.TestCase):
|
||||
"""Behaviour of WebSearch._search_searxng with a stubbed HTTP layer."""
|
||||
|
||||
def setUp(self):
|
||||
self.tool = web_search_module.WebSearch()
|
||||
|
||||
def test_search_searxng_requires_url(self):
|
||||
"""Without an instance URL, the backend fails fast with guidance."""
|
||||
with patch.object(web_search_module, "_get_searxng_url", return_value=""):
|
||||
result = self.tool._search_searxng("q", 10)
|
||||
self.assertEqual(result.status, "error")
|
||||
self.assertIn("SearXNG instance URL not configured", result.result)
|
||||
|
||||
def test_search_searxng_maps_results(self):
|
||||
"""content/engine map to snippet/siteName; count clamps the list."""
|
||||
payload = {
|
||||
"results": [
|
||||
{"title": "T1", "url": "https://a.example/1", "content": "c1",
|
||||
"engine": "duckduckgo", "score": 1.2},
|
||||
{"title": "T2", "url": "https://a.example/2"},
|
||||
]
|
||||
}
|
||||
with patch.object(web_search_module, "_get_searxng_url", return_value="https://searx.example/"), \
|
||||
patch.object(web_search_module.requests, "get",
|
||||
return_value=_fake_response(200, payload)) as mock_get:
|
||||
result = self.tool._search_searxng("cowagent", 10)
|
||||
|
||||
self.assertEqual(result.status, "success")
|
||||
self.assertEqual(result.result["backend"], "searxng")
|
||||
self.assertEqual(result.result["total"], 2)
|
||||
first, second = result.result["results"]
|
||||
self.assertEqual(first["snippet"], "c1")
|
||||
self.assertEqual(first["siteName"], "duckduckgo")
|
||||
self.assertEqual(second["snippet"], "")
|
||||
# Trailing slash on the instance URL is normalized; format=json is used.
|
||||
url = mock_get.call_args[0][0]
|
||||
self.assertIn("https://searx.example/search?", url)
|
||||
self.assertIn("format=json", url)
|
||||
|
||||
def test_search_searxng_non_json_response(self):
|
||||
"""A non-JSON body maps to a clear error message."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.text = "<html>not json</html>"
|
||||
with patch.object(web_search_module, "_get_searxng_url", return_value="https://searx.example"), \
|
||||
patch.object(web_search_module.requests, "get", return_value=resp):
|
||||
result = self.tool._search_searxng("q", 10)
|
||||
self.assertEqual(result.status, "error")
|
||||
self.assertIn("non-JSON response", result.result)
|
||||
|
||||
def test_search_searxng_error_status_mapping(self):
|
||||
"""401/403/429 map to specific messages; other statuses are generic."""
|
||||
cases = {
|
||||
401: "requires authentication",
|
||||
403: "access forbidden",
|
||||
429: "rate limit reached",
|
||||
500: "HTTP 500",
|
||||
}
|
||||
for status_code, fragment in cases.items():
|
||||
with self.subTest(status_code=status_code):
|
||||
with patch.object(web_search_module, "_get_searxng_url", return_value="https://searx.example"), \
|
||||
patch.object(web_search_module.requests, "get",
|
||||
return_value=_fake_response(status_code)):
|
||||
result = self.tool._search_searxng("q", 10)
|
||||
self.assertEqual(result.status, "error")
|
||||
self.assertIn(fragment, result.result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user