diff --git a/cloud/tests/test_websearch_mcp.py b/cloud/tests/test_websearch_mcp.py
index 36673de..a5ed5ed 100644
--- a/cloud/tests/test_websearch_mcp.py
+++ b/cloud/tests/test_websearch_mcp.py
@@ -67,18 +67,38 @@ def _exa_body():
class ChainTestCase(unittest.TestCase):
- def test_fallback_perplexity_to_exa_and_key_not_leaked(self):
+ def test_default_auto_frozen_falls_back_searxng_to_duckduckgo_and_key_not_leaked(self):
+ # Paid-freeze update of the old perplexity -> exa fallback
+ # test: default auto no longer contains any paid backend,
+ # even when all four paid keys are set, so the fallback
+ # that still exists by default is searxng -> duckduckgo
+ # and no paid URL is ever touched. The old perplexity ->
+ # exa fallback is preserved, but only under
+ # WEBSEARCH_ENABLE_PAID=1; see test_websearch_paid_freeze.py.
+ html = (
+ 'DDG Title'
+ 'ddg snippet')
http = FakeHttp([
- ("perplexity", {}, 500),
- ("api.exa.ai", _exa_body(), 200),
+ ("searx", {}, 500),
+ ("duckduckgo", html, 200),
])
+ env = dict(_FAKE_KEYS)
+ env["SEARXNG_URL"] = "http://searx.test"
out = search("test query", provider="auto",
- env=dict(_FAKE_KEYS), http=http)
- self.assertEqual(out["provider"], "exa")
+ env=env, http=http)
+ self.assertEqual(out["provider"], "duckduckgo")
self.assertEqual(out["results"][0]["url"],
- "https://example.com/exa")
- self.assertEqual(out["attempted"][0]["provider"], "perplexity")
+ "https://example.com/ddg")
+ self.assertEqual(out["attempted"][0]["provider"], "searxng")
self.assertFalse(out["attempted"][0]["ok"])
+ self.assertEqual(
+ [a["provider"] for a in out["attempted"]],
+ ["searxng", "duckduckgo"])
+ for _method, url, _payload in http.seen:
+ for paid in ("perplexity", "exa.ai", "tavily",
+ "search.brave.com"):
+ self.assertNotIn(paid, url)
blob = json.dumps(out)
for secret in _FAKE_KEYS.values():
self.assertNotIn(secret, blob)
diff --git a/cloud/tests/test_websearch_paid_freeze.py b/cloud/tests/test_websearch_paid_freeze.py
new file mode 100644
index 0000000..77a1553
--- /dev/null
+++ b/cloud/tests/test_websearch_paid_freeze.py
@@ -0,0 +1,280 @@
+"""Paid-freeze tests: default auto is free-only, pin still works,
+unfreeze is explicit (WEBSEARCH_ENABLE_PAID=1, exact value "1").
+
+No network, no real keys: every HTTP call goes through a FakeHttp,
+and the fake key values are deliberately distinctive so a leak into
+an error string or a tool result would be caught by assertion.
+"""
+
+import json
+import os
+import socket
+import sys
+import unittest
+import urllib.error
+from pathlib import Path
+from unittest import mock
+
+try:
+ from cloud.websearch import (
+ DEFAULT_CHAIN, DEFAULT_FREE_CHAIN, HttpClient, WebSearchError,
+ available_providers, paid_search_enabled,
+ resolve_provider_chain, search)
+ from cloud.websearch_mcp import handle_request
+except ImportError:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+ from cloud.websearch import (
+ DEFAULT_CHAIN, DEFAULT_FREE_CHAIN, HttpClient, WebSearchError,
+ available_providers, paid_search_enabled,
+ resolve_provider_chain, search)
+ from cloud.websearch_mcp import handle_request
+
+_FAKE_KEYS = {
+ "PERPLEXITY_API_KEY": "pplx-SECRET-111",
+ "EXA_API_KEY": "exa-SECRET-222",
+ "TAVILY_API_KEY": "tvly-SECRET-333",
+ "BRAVE_API_KEY": "brave-SECRET-444",
+}
+
+_DDG_HTML = (
+ 'DDG Title'
+ 'ddg snippet')
+
+
+class FakeHttp:
+ """post_json/get keyed by URL substring; failures raise like HTTP."""
+
+ def __init__(self, routes):
+ self.routes = routes
+ self.seen = []
+
+ def post_json(self, url, headers, payload):
+ self.seen.append(("POST", url, payload))
+ return self._route(url)
+
+ def get(self, url, headers=None):
+ self.seen.append(("GET", url, headers))
+ return self._route(url)
+
+ def _route(self, url):
+ for needle, body, status in self.routes:
+ if needle in url:
+ if status != 200:
+ raise WebSearchError("HTTP %s" % status)
+ if isinstance(body, dict):
+ return 200, json.dumps(body).encode(), "application/json"
+ return 200, body.encode(), "text/html"
+ raise WebSearchError("HTTP 404")
+
+
+def _searxng_body():
+ return {"results": [
+ {"title": "SearXNG Hit", "url": "https://example.com/sx",
+ "content": "searxng snippet"}]}
+
+
+def _paid_env(**extra):
+ env = dict(_FAKE_KEYS)
+ env["SEARXNG_URL"] = "http://searx.test"
+ env.update(extra)
+ return env
+
+
+def _paid_hosts_seen(http):
+ hosts = []
+ for _method, url, _payload in http.seen:
+ for paid in ("perplexity", "exa.ai", "tavily",
+ "search.brave.com"):
+ if paid in url:
+ hosts.append(paid)
+ return hosts
+
+
+class FrozenDefaultTestCase(unittest.TestCase):
+ def test_default_auto_excludes_paid_even_with_all_keys(self):
+ env = _paid_env()
+ self.assertEqual(available_providers(env),
+ ["searxng", "duckduckgo"])
+ self.assertEqual(resolve_provider_chain("auto", env),
+ ["searxng", "duckduckgo"])
+ http = FakeHttp([("searx", _searxng_body(), 200)])
+ out = search("q", provider="auto", env=env, http=http)
+ self.assertEqual(out["provider"], "searxng")
+ self.assertEqual(_paid_hosts_seen(http), [])
+
+ def test_default_auto_without_searxng_url_is_duckduckgo_only(self):
+ env = dict(_FAKE_KEYS)
+ self.assertEqual(resolve_provider_chain("auto", env),
+ ["duckduckgo"])
+ http = FakeHttp([("duckduckgo", _DDG_HTML, 200)])
+ out = search("q", provider="auto", env=env, http=http)
+ self.assertEqual(out["provider"], "duckduckgo")
+ self.assertEqual(_paid_hosts_seen(http), [])
+
+ def test_searxng_failure_never_falls_back_to_paid(self):
+ http = FakeHttp([("searx", {}, 500),
+ ("duckduckgo", "challenge", 200)])
+ with self.assertRaises(WebSearchError) as ctx:
+ search("q", provider="auto", env=_paid_env(), http=http)
+ self.assertEqual(
+ [a["provider"] for a in ctx.exception.attempted],
+ ["searxng", "duckduckgo"])
+ self.assertEqual(_paid_hosts_seen(http), [])
+ # No fabricated result: the error names both free stages.
+ self.assertIn("searxng", str(ctx.exception))
+ self.assertIn("duckduckgo", str(ctx.exception))
+
+ def test_duckduckgo_fallback_failure_reports_clearly(self):
+ # DuckDuckGo under the VM/host egress returns a 202-style
+ # robot-challenge page: the parser yields no results, which
+ # must surface as an honest "no results" failure for that
+ # stage, never as a fabricated hit.
+ http = FakeHttp([("searx", {}, 500),
+ ("duckduckgo", "anomaly", 200)])
+ with self.assertRaises(WebSearchError) as ctx:
+ search("q", provider="auto", env=_paid_env(), http=http)
+ attempted = {a["provider"]: a for a in ctx.exception.attempted}
+ self.assertEqual(attempted["duckduckgo"]["error"], "no results")
+ self.assertFalse(attempted["duckduckgo"]["ok"])
+ for secret in _FAKE_KEYS.values():
+ self.assertNotIn(secret, str(ctx.exception))
+
+
+class ExplicitPinTestCase(unittest.TestCase):
+ def test_explicit_pin_paid_still_works_while_frozen(self):
+ http = FakeHttp([
+ ("api.tavily.com",
+ {"results": [{"title": "T", "url": "https://example.com/t",
+ "content": "tavily content"}]}, 200),
+ ])
+ out = search("q", provider="tavily", env=_paid_env(), http=http)
+ self.assertEqual(out["provider"], "tavily")
+ self.assertEqual(out["results"][0]["snippet"], "tavily content")
+
+ def test_explicit_pin_paid_failure_does_not_fall_back(self):
+ http = FakeHttp([("perplexity", {}, 500),
+ ("api.exa.ai", {"results": [
+ {"title": "E", "url": "https://example.com/e",
+ "text": "x"}]}, 200)])
+ with self.assertRaises(WebSearchError):
+ search("q", provider="perplexity", env=_paid_env(), http=http)
+ self.assertEqual(_paid_hosts_seen(http), ["perplexity"])
+
+ def test_frozen_default_applies_to_scope_code_too(self):
+ body = {"results": [
+ {"title": "GH", "url": "https://github.com/a/b",
+ "content": "code hit"},
+ {"title": "Other", "url": "https://example.com/other",
+ "content": "off-scope"}]}
+ http = FakeHttp([("searx", body, 200)])
+ out = search("q", provider="auto", env=_paid_env(),
+ http=http, scope="code")
+ self.assertEqual(out["provider"], "searxng")
+ self.assertEqual(
+ [r["url"] for r in out["results"]], ["https://github.com/a/b"])
+ self.assertEqual(_paid_hosts_seen(http), [])
+
+
+class UnfreezeSwitchTestCase(unittest.TestCase):
+ def test_switch_off_by_default_and_non_1_values_do_not_unfreeze(self):
+ self.assertFalse(paid_search_enabled({}))
+ for value in ("0", "true", "yes", "", "on"):
+ env = _paid_env(WEBSEARCH_ENABLE_PAID=value)
+ self.assertFalse(paid_search_enabled(env), value)
+ self.assertEqual(resolve_provider_chain("auto", env),
+ ["searxng", "duckduckgo"])
+
+ def test_switch_on_restores_legacy_chain_and_fallback(self):
+ env = _paid_env(WEBSEARCH_ENABLE_PAID="1")
+ self.assertTrue(paid_search_enabled(env))
+ self.assertEqual(resolve_provider_chain("auto", env),
+ list(DEFAULT_CHAIN))
+ # The pre-freeze perplexity -> exa fallback, preserved
+ # verbatim under the explicit switch (plus the no-leak
+ # assertion from the old default-chain test).
+ http = FakeHttp([
+ ("perplexity", {}, 500),
+ ("api.exa.ai", {"results": [
+ {"title": "Exa Hit", "url": "https://example.com/exa",
+ "text": "exa snippet", "score": 0.9}]}, 200),
+ ])
+ out = search("test query", provider="auto", env=env, http=http)
+ self.assertEqual(out["provider"], "exa")
+ self.assertEqual(out["results"][0]["url"],
+ "https://example.com/exa")
+ self.assertEqual(out["attempted"][0]["provider"], "perplexity")
+ self.assertFalse(out["attempted"][0]["ok"])
+ blob = json.dumps(out)
+ for secret in _FAKE_KEYS.values():
+ self.assertNotIn(secret, blob)
+
+ def test_constants_state_the_contract(self):
+ self.assertEqual(DEFAULT_FREE_CHAIN, ("searxng", "duckduckgo"))
+ self.assertEqual(
+ DEFAULT_CHAIN,
+ ("perplexity", "exa", "tavily", "brave", "searxng",
+ "duckduckgo"))
+
+
+class TimeoutClarityTestCase(unittest.TestCase):
+ def test_http_client_maps_timeout_to_explicit_error(self):
+ client = HttpClient(timeout=20)
+ with mock.patch("urllib.request.urlopen",
+ side_effect=socket.timeout("timed out")):
+ with self.assertRaises(WebSearchError) as ctx:
+ client.get("http://searx.test/search?q=x")
+ self.assertIn("timeout after 20s", str(ctx.exception))
+
+ with mock.patch(
+ "urllib.request.urlopen",
+ side_effect=urllib.error.URLError(
+ socket.timeout("timed out"))):
+ with self.assertRaises(WebSearchError) as ctx:
+ client.get("http://searx.test/search?q=x")
+ self.assertIn("timeout after 20s", str(ctx.exception))
+
+ def test_http_client_does_not_relabel_dns_failure_as_timeout(self):
+ client = HttpClient(timeout=20)
+ with mock.patch(
+ "urllib.request.urlopen",
+ side_effect=urllib.error.URLError(
+ "Temporary failure in name resolution")):
+ with self.assertRaises(WebSearchError) as ctx:
+ client.get("http://searx.test/search?q=x")
+ self.assertNotIn("timeout", str(ctx.exception))
+ self.assertIn("name resolution", str(ctx.exception))
+
+ def test_search_reports_timeout_as_error_not_no_results(self):
+ class TimeoutHttp(FakeHttp):
+ def get(self, url, headers=None):
+ self.seen.append(("GET", url, headers))
+ if "searx" in url:
+ raise WebSearchError(
+ "timeout after 20s waiting for HTTP response")
+ return self._route(url)
+
+ http = TimeoutHttp([("duckduckgo", _DDG_HTML, 200)])
+ out = search("q", provider="auto", env=_paid_env(), http=http)
+ self.assertEqual(out["provider"], "duckduckgo")
+ attempted = {a["provider"]: a for a in out["attempted"]}
+ self.assertIn("timeout after 20s",
+ attempted["searxng"]["error"])
+ self.assertNotEqual(attempted["searxng"]["error"], "no results")
+
+
+class McpDescriptionTestCase(unittest.TestCase):
+ def test_tools_list_describes_frozen_default_and_unfreeze(self):
+ resp = handle_request(
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
+ tool = [t for t in resp["result"]["tools"]
+ if t["name"] == "web_search"][0]
+ desc = tool["description"]
+ self.assertIn("SearXNG", desc)
+ self.assertIn("DuckDuckGo", desc)
+ self.assertIn("WEBSEARCH_ENABLE_PAID=1", desc)
+ self.assertIn("paid", desc.lower())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/cloud/tests/test_websearch_worker.py b/cloud/tests/test_websearch_worker.py
index 90d0956..756aa92 100644
--- a/cloud/tests/test_websearch_worker.py
+++ b/cloud/tests/test_websearch_worker.py
@@ -124,10 +124,24 @@ class DockerEnvTestCase(unittest.TestCase):
self.assertIsNone(err)
self.assertEqual(env.get("SEARXNG_URL"), "http://searx.diante.me")
+ def test_paid_unfreeze_switch_passes_into_container_env(self):
+ # The freeze switch must reach the MCP process (which reads
+ # only config.toml env, never worker env); without this
+ # passthrough, WEBSEARCH_ENABLE_PAID=1 on the host would
+ # silently leave default auto frozen inside containers.
+ from unittest import mock
+ with mock.patch.dict(os.environ,
+ {"WEBSEARCH_ENABLE_PAID": "1"}):
+ ok, _, _, env, err = self._docker_context()
+ self.assertTrue(ok)
+ self.assertIsNone(err)
+ self.assertEqual(env.get("WEBSEARCH_ENABLE_PAID"), "1")
+
def test_no_search_env_means_no_extra_env(self):
from unittest import mock
with mock.patch.dict(os.environ, {}, clear=False):
- for k in ("SEARXNG_URL", "PERPLEXITY_API_KEY", "EXA_API_KEY",
+ for k in ("SEARXNG_URL", "WEBSEARCH_ENABLE_PAID",
+ "PERPLEXITY_API_KEY", "EXA_API_KEY",
"TAVILY_API_KEY", "BRAVE_API_KEY"):
os.environ.pop(k, None)
ok, _, _, env, err = self._docker_context()
diff --git a/cloud/websearch.py b/cloud/websearch.py
index e42a72a..f1a3288 100644
--- a/cloud/websearch.py
+++ b/cloud/websearch.py
@@ -21,6 +21,16 @@ Design notes (what is copied from oh-my-pi, and what is not):
here is a small stdlib HTML parser: script/style stripped, text
collapsed, long pages truncated head+tail like Hermes' deterministic
budget (default 15000 chars, 75% head / 25% tail, ``[TRUNCATED]``).
+- FROZEN default chain (paid freeze): the default ``auto`` chain
+ contains only the free backends, SearXNG then DuckDuckGo, even
+ when paid keys happen to be set -- a SearXNG failure never falls
+ back to a paid backend, so default use can never incur paid cost.
+ The four paid backends (Perplexity, Exa, Tavily, Brave) keep their
+ code, tests and docs, and still run when explicitly pinned
+ (provider="tavily" etc.), which is a deliberate override, not a
+ default. Only WEBSEARCH_ENABLE_PAID=1 (exact value "1", default
+ off) restores the old default chain; unfreezing is an explicit act
+ and may incur paid cost, never a silent default.
Security notes:
- API keys come only from the process environment, never from
@@ -57,6 +67,9 @@ from html.parser import HTMLParser
__all__ = [
"DEFAULT_CHAIN",
+ "DEFAULT_FREE_CHAIN",
+ "PAID_PROVIDERS",
+ "paid_search_enabled",
"CODE_SEARCH_DOMAINS",
"WebSearchError",
"SearchResult",
@@ -72,6 +85,12 @@ __all__ = [
DEFAULT_CHAIN = ("perplexity", "exa", "tavily", "brave", "searxng",
"duckduckgo")
+# Paid backends, frozen out of the default auto chain (see the module
+# docstring). DEFAULT_CHAIN keeps the legacy full order so the
+# unfrozen chain is exactly the old one; DEFAULT_FREE_CHAIN is what
+# default auto actually walks while frozen.
+PAID_PROVIDERS = ("perplexity", "exa", "tavily", "brave")
+DEFAULT_FREE_CHAIN = ("searxng", "duckduckgo")
DEFAULT_FETCH_CHAR_LIMIT = 15000
# Programming-only search scope (``search(..., scope="code")``).
@@ -246,6 +265,27 @@ class HttpClient:
raise WebSearchError(
"HTTP %s" % exc.code) from _ProviderHttpError(
exc.code, body)
+ except (socket.timeout, TimeoutError) as exc:
+ # A timeout is a timeout, never "no results": the caller
+ # (search's attempted entry, named by provider) must be
+ # able to tell which stage timed out, and a timed-out
+ # SearXNG must not be rewritten as an empty answer.
+ # HTTPError is a URLError subclass, so this branch must
+ # stay after the HTTPError branch above.
+ raise WebSearchError(
+ "timeout after %ss waiting for HTTP response"
+ % self.timeout) from exc
+ except urllib.error.URLError as exc:
+ reason = getattr(exc, "reason", None)
+ if isinstance(reason, (socket.timeout, TimeoutError)):
+ raise WebSearchError(
+ "timeout after %ss waiting for HTTP response"
+ % self.timeout) from exc
+ # DNS / connection failures keep their own message;
+ # they are not timeouts and must not be relabelled.
+ raise WebSearchError(
+ "URL error: %s" % reason if reason else "URL error"
+ ) from exc
def get_text(self, url, headers=None):
status, body, _ctype = self.get(url, headers)
@@ -534,11 +574,32 @@ _PROVIDERS = {
}
-def available_providers(env=None):
- """Providers usable right now: keyed ones with a key, plus free ones."""
+def paid_search_enabled(env=None):
+ """True only when the paid-unfreeze switch is explicitly on.
+
+ The switch is WEBSEARCH_ENABLE_PAID, and only the exact value
+ "1" counts (same strict convention as WEBSEARCH_ALLOW_PRIVATE_URLS
+ elsewhere in this module): any other value, including "true" or
+ "yes", leaves the paid backends frozen out of default auto.
+ Explicit pinning is unaffected by this switch either way.
+ """
env = env if env is not None else os.environ
+ return env.get("WEBSEARCH_ENABLE_PAID") == "1"
+
+
+def available_providers(env=None):
+ """Providers usable right now, under the paid-freeze rule.
+
+ Frozen (default): only the free chain -- SearXNG when its URL
+ is set, plus DuckDuckGo -- even when paid keys are present.
+ Unfrozen (WEBSEARCH_ENABLE_PAID=1): the legacy full chain,
+ keyed backends only when their key is set, as before.
+ """
+ env = env if env is not None else os.environ
+ chain = (DEFAULT_CHAIN if paid_search_enabled(env)
+ else DEFAULT_FREE_CHAIN)
out = []
- for name in DEFAULT_CHAIN:
+ for name in chain:
if name in _FREE_PROVIDERS:
if name == "searxng" and not env.get("SEARXNG_URL"):
continue
@@ -552,8 +613,11 @@ def resolve_provider_chain(provider="auto", env=None):
"""Resolve the chain oh-my-pi style: auto walks, pinned does not.
``provider`` may be "auto" (default chain, filtered to what is
- configured), a single name (that provider only, even if it fails),
- or a comma-separated list (that order only). Unknown names raise
+ configured -- frozen by default to the free chain, SearXNG then
+ DuckDuckGo, unless WEBSEARCH_ENABLE_PAID=1 restores the legacy
+ full chain), a single name (that provider only, even if it fails
+ -- pinning a paid backend bypasses the freeze, deliberately), or
+ a comma-separated list (that order only). Unknown names raise
ValueError before any HTTP happens.
"""
env = env if env is not None else os.environ
diff --git a/cloud/websearch_mcp.py b/cloud/websearch_mcp.py
index 7c786c2..280d312 100644
--- a/cloud/websearch_mcp.py
+++ b/cloud/websearch_mcp.py
@@ -35,9 +35,19 @@ _TOOLS = [
"Search the web and return ranked results with title, URL "
"and snippet, plus a synthesised answer when the provider "
"supplies one. Use web_fetch to read specific pages. "
- "provider may be 'auto' (walk Perplexity, Exa, Tavily, "
- "Brave, then keyless SearXNG / DuckDuckGo, falling back "
- "on failure) or a single provider name to pin it. "
+ "provider may be 'auto' (frozen default: keyless SearXNG "
+ "first, then DuckDuckGo as the free fallback, falling "
+ "back on failure; the paid backends Perplexity, Exa, "
+ "Tavily and Brave are NOT in the default chain even "
+ "when their keys are set, and a SearXNG failure never "
+ "falls back to a paid backend, so default use never "
+ "incurs paid cost) or a single provider name to pin it "
+ "(pinning a paid backend still calls it: that is an "
+ "explicit override, not a default). Set "
+ "WEBSEARCH_ENABLE_PAID=1 to restore the old default "
+ "chain (Perplexity, Exa, Tavily, Brave, SearXNG, "
+ "DuckDuckGo); unfreezing is explicit and may incur "
+ "paid cost. "
"scope 'code' restricts results to programming sources "
"only (official language docs, GitHub, package "
"registries, Stack Overflow, CVE/NVD); anything else "
diff --git a/cloud/worker.py b/cloud/worker.py
index 805f865..564cc56 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -3188,9 +3188,15 @@ class Worker:
# auto 链会跳过 SearXNG、只剩 DuckDuckGo(实弹 e2e 已证实)。
# 只透传已设置的项,值绝不进日志;付费 key 与网关 key 一样
# 走 extra_env(容器内进程本就需要它调 API)。
+ # WEBSEARCH_ENABLE_PAID rides along so the paid-freeze
+ # switch set on the host actually reaches the MCP process
+ # (which reads only config.toml env, never worker env).
+ # Default is frozen; the switch is honoured only when set
+ # to exactly "1" (see websearch.paid_search_enabled).
search_env = {
k: os.environ[k]
for k in ("SEARXNG_URL", "SEARXNG_ENGINES",
+ "WEBSEARCH_ENABLE_PAID",
"PERPLEXITY_API_KEY", "EXA_API_KEY",
"TAVILY_API_KEY", "BRAVE_API_KEY")
if os.environ.get(k)
diff --git a/docs/web-search-mcp.md b/docs/web-search-mcp.md
index 0ec0759..cf47dc4 100644
--- a/docs/web-search-mcp.md
+++ b/docs/web-search-mcp.md
@@ -20,6 +20,11 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
- `cloud/tests/test_code_search_ext.py`:任务级
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
+- `cloud/tests/test_websearch_paid_freeze.py`:付费冻结单测
+ (默认 auto 无付费后端、SearXNG 失败不回落付费、显式
+ pin 仍工作、解冻开关关时不变 / 开时恢复旧链、
+ DuckDuckGo 兜底失败报错清晰、超时报错清晰;全部
+ mock HTTP,不联网、不需要真实 key)。
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
@@ -39,7 +44,48 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
超长按头 75% + 尾 25% 截断并标 `[TRUNCATED]`;只接受 http(s),拒绝
localhost 与字面私有 IP;抽取与直接抓取都失败时报错,不伪造页面。
-## Provider 链(auto 顺序)
+## Provider 链:默认冻结,显式 pin 与显式解冻除外
+
+冻结语义(本轨起,默认 `auto` 只走免费路线,顺序固定):
+
+1. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
+ 实例且 JSON 格式已打开。默认参数:`engines=bing,360search`
+ (2026-10-30 主机实测:默认 general 类别 20.0s 撞超时,
+ pinned 两引擎 0.47s;`SEARXNG_ENGINES` 可覆盖,空值恢复
+ 不 pin)。`SEARXNG_URL` 在代码里无默认值:未配时该后端
+ 直接报 `searxng not configured (SEARXNG_URL unset)`,链
+ 只剩 DuckDuckGo;生产值 `http://searx.diante.me` 来自主
+ 机 env(见“生产接线”),不在代码里写死;主机 env 实测
+ 仅有 `SEARXNG_URL` 这一行、无 `SEARXNG_ENGINES` 行,
+ 所以默认 pin(bing,360search)生效。注意是 http 不是
+ https:主机 nginx 只听 80、无 443 vhost,https 会在 TLS
+ 层证书主机名不匹配失败,根本到不了 SearXNG。
+2. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
+ 免 key 兜底,布局漂移时只会返回空结果并继续走链,
+ 不会把工具打崩;但它在两种出口下都不可作可用兜底:
+ 本 VM 直连回 202 + 机器人挑战页、0 条,主机 Tinyproxy
+ 路径约 25 秒超时。兜底失败时记为 `no results` / 超时并
+ 如实报错,不伪造结果;因此 SearXNG 挂时冻结默认链可能
+ 以诚实失败告终,这是已知边界,不是静默降级。
+
+冻结保证:即使环境里配了付费 key,默认 auto 也绝不调用
+Perplexity / Exa / Tavily / Brave、绝不产生付费花费;
+SearXNG 失败后默认也不回落到付费后端,只走 DuckDuckGo。
+付费后端的代码、测试、文档一律保留,未删除。
+
+显式 pin 仍工作:`provider="tavily"` / `"exa"` /
+`"perplexity"` / `"brave"` 显式指定时照常调用,这是用户
+有意的覆盖,不是默认行为;pin 失败不回退(与冻结前一致)。
+
+解冻开关:`WEBSEARCH_ENABLE_PAID=1`(且仅精确值 `1`,
+`true` / `yes` / `0` 都不算开,默认关)时,默认 auto
+恢复旧链,顺序为 Perplexity → Exa → Tavily → Brave →
+SearXNG → DuckDuckGo(旧链里只试有 key 的付费后端 +
+已配 URL 的 SearXNG + DuckDuckGo)。解冻是显式动作,
+会产生付费花费,绝不能被默认链静默触发;开关走 worker
+环境透传进 MCP config env(见“生产接线”)。
+
+旧链各后端(解冻后 / 显式 pin 时)的请求形态不变:
1. `perplexity`:`POST https://api.perplexity.ai/chat/completions`,
模型 `sonar`,`Authorization: Bearer $PERPLEXITY_API_KEY`。
@@ -53,18 +99,16 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
结果,总结留给模型)。
4. `brave`:`GET https://api.search.brave.com/res/v1/web/search`,
头 `X-Subscription-Token`,`extra_snippets: true`。
-5. `searxng`:`GET $SEARXNG_URL/search?format=json`,需自托管
- 实例且 JSON 格式已打开。
-6. `duckduckgo`:解析 `https://html.duckduckgo.com/html/`,
- 免 key 兜底,布局漂移时只会返回空结果并继续走链,
- 不会把工具打崩。
-规则:auto 只试已配置的后端(有 key 的付费后端 + 已配
-URL 的 SearXNG + DuckDuckGo);任一后端 HTTP 失败或
-0 结果都继续下一个;全部失败返回各后端错误(不含 key)。
-`provider` pin 单个名字时不回退,失败即报错。
-可用 `WEBSEARCH_PROVIDERS`(逗号列表)改默认链顺序,
-`WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。
+规则:任一后端 HTTP 失败或 0 结果都继续下一个;全部失败
+返回各后端错误(不含 key)。超时失败报
+`timeout after Ns waiting for HTTP response`(N 为
+`WEBSEARCH_TIMEOUT`,默认 20 秒),并记在该后端的
+attempted 条目里,绝不把超时写成“无结果”或空答案。
+`WEBSEARCH_TIMEOUT`(秒,默认 20)改单次超时。另注意:
+旧文档曾写可用 `WEBSEARCH_PROVIDERS` 改默认链顺序,
+代码里从未实现该变量,本轨已删此说法(不另新加该变量,
+保持改动最小)。
## 接入 codex(测试项目做法)
@@ -97,15 +141,19 @@ Docker 租户必须传容器内路径
- egress 白名单:`deploy/egress/tinyproxy-filter` 已加
Perplexity、Exa、Tavily、Brave、DuckDuckGo 与
`searx.diante.me`(精确匹配,不用 diante.me 通配,否则
- 任务经代理也能摸到 api.diante.me 控制面)。主机上须把
+ 任务经代理也能摸到 api.diante.me 控制面;filter 行即
+ `^searx\.diante\.me$`,本轨不改这行)。主机上须把
该文件拷到 `/etc/tinyproxy/tinyproxy-filter` 并重载
tinyproxy,重载后先验中转域名还通,再验新域名。
- 自托管 SearXNG(公开子域名路):主机 Docker 跑
- `searxng/searxng`,经 nginx 反代到 `searx.diante.me`
- (DNS A 记录 + certbot 证书),MCP 侧
- `SEARXNG_URL=https://searx.diante.me` 走 worker 进程
+ `searxng/searxng`,经 nginx(仅听 80,无 443 vhost、无
+ 证书)反代到 `searx.diante.me`,MCP 侧
+ `SEARXNG_URL=http://searx.diante.me` 走 worker 进程
环境(`/etc/codex-app-server/env`)继承到 codex 再到
- MCP,不写进 config.toml。公开实例的代价是可能被外人
+ MCP,不写进 config.toml。`WEBSEARCH_ENABLE_PAID` 同理
+ 走 worker 的 search_env 透传进 `[mcp_servers.websearch.env]`
+ (worker 代码已含该键);主机侧改 env 文件后需重启相关
+ unit 才生效——那是部署动作,不在本轨。公开实例的代价是可能被外人
刷:SearXNG limiter 保持开启,JSON 格式是 MCP 要用的
唯一格式。未加域名进白名单之前,Docker 任务的搜索
会被代理 403 挡住(fail closed,工具报错,不会静默绕过)。
@@ -267,6 +315,10 @@ filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
- 本批只做代码 + 单测 + 测试租户目录的配置合并验证,
未推分支、未建 PR、未合并、未部署、未改生产 worker
与 egress 配置、未跑真实 codex 任务。
+- 付费冻结本轨同样只在本地提交里:生产默认链要等部署
+ 新代码后才变;解冻开关在主机上默认仍是关(除非主机
+ env 显式设为 `WEBSEARCH_ENABLE_PAID=1`,且那需要
+ 主机侧改 env + 重启 unit,属部署动作)。
- DuckDuckGo 的 HTML 解析是兜底手段,布局一变就可能
0 结果;它存在的意义是“没 key 也能走通链路”,不是
长期依赖的搜索后端。实测(2026-10-28,VM 出口):
@@ -335,16 +387,19 @@ mock 补不了、也证明不了的部分:
### Live 对照(与 mock 分开写,未验证的不写成已验证)
-- 无 key 的 SearXNG 路线:本轨在 VM 测试环境实测一次
- (`SEARXNG_URL=https://searx.diante.me`,`provider="searxng"`,
- 产品自身 `search()` 代码):失败,`URLError: Remote end
- closed connection without response`。这是在本 VM 出口下
- 的一次结果,不能据此断言 SearXNG 服务本身挂了(生产
- 文档记载主机侧经代理 pinned 引擎曾 0.47s 通),live
- 对照在本测试环境记为**未完成**,需在主机侧可达的环境
- 重测;测试文件里的 live 烟囱测试默认跳过,只在
- `WEBSEARCH_EVAL_LIVE_SEARXNG=1` 且配了 `SEARXNG_URL`
- 时才跑。
+- 无 key 的 SearXNG 路线:旧 VM 测试曾用
+ `SEARXNG_URL=https://searx.diante.me` 失败(`URLError:
+ Remote end closed connection without response`)——该失败
+ 现已归因:https 是错的,主机 nginx 只听 80、无 443
+ vhost,https 在 TLS 层证书主机名不匹配失败,根本没到
+ SearXNG,不能据此断言服务挂了。2026-11-01 探针实测:
+ http 经同一 Tinyproxy 0.34 秒回 200、16 条结果,服务
+ 活着;主机 env 仅 `SEARXNG_URL=http://searx.diante.me`
+ 一行、无 `SEARXNG_ENGINES`,默认 pin 生效。容器内真实
+ 任务派发仍是 blocker(不由本轨创建生产任务),live
+ 对照在该项上仍记为**未完成**;测试文件里的 live 烟囱
+ 测试默认跳过,只在 `WEBSEARCH_EVAL_LIVE_SEARXNG=1`
+ 且配了 `SEARXNG_URL` 时才跑。
- 付费后端 live 效果(Tavily extract / Exa contents /
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
**一律未验证**,不编造效果,逐项列为 blocker(见回执),