Merge branch 'feat/web-search-tavily-searxng'

This commit is contained in:
zhayujie committed 2026-09-08 17:37:10 +08:00
commit f04f3524e2
7 files changed
+446 -30

No files matched your search

+135 -4
View File
@@ -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,
})
+61 -8
View File
@@ -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');
+29 -3
View File
@@ -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 ""
+7 -4
View File
@@ -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>
+7 -4
View File
@@ -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>
+7 -4
View File
@@ -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
View File
@@ -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()