From 4d87290d396a66974ea58d8a984bf549cc7c066f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:22:30 -0700 Subject: [PATCH] feat: keyless web traffic splits 50/50 between Exa and Parallel like opencode Unpinned zero-credential installs now pick Exa or Parallel by the parity of the per-process random session id (stable within a process, even split fleet-wide) instead of always favoring Parallel. An explicit hermes tools selection (web.backend / per-capability keys) bypasses the split entirely; the runner-up vendor stays in the walk as fallback. Live E2E: 6 fresh processes split 3/3 between vendors, each performed a real keyless search via its picked endpoint; explicit pin verified. --- agent/web_search_registry.py | 33 ++++++++++++++++--- tests/tools/test_web_keyless_fallback.py | 25 +++++++++++--- tools/web_tools.py | 4 +-- website/docs/user-guide/configuration.md | 2 +- .../docs/user-guide/features/web-search.md | 6 ++-- 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index b78442cc50..c4aa5ea62e 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -169,15 +169,38 @@ _LEGACY_PREFERENCE = ( # Keyless free-tier walk — strictly LAST-resort, tried only after the # availability-filtered legacy walk finds nothing (i.e. the user has zero # web credentials and no importable ddgs). These providers expose public -# anonymous MCP endpoints (see plugins/web/keyless_mcp.py); order favors -# Parallel, whose free tier has proven more permissive than Exa's per-IP -# rate limit. Disable the tier with ``web.keyless_fallback: false``. +# anonymous MCP endpoints (see plugins/web/keyless_mcp.py). Like opencode, +# unpinned keyless traffic is split 50/50 between Exa and Parallel per +# process (see _keyless_preference()); an explicit `hermes tools` pick +# (web.backend / web._backend) bypasses this walk entirely. +# Disable the tier with ``web.keyless_fallback: false``. _KEYLESS_PREFERENCE = ( - "parallel", "exa", + "parallel", ) +def _keyless_preference() -> tuple: + """Return the keyless walk order, split 50/50 per process. + + Mirrors opencode's session-checksum A/B split between Exa and + Parallel: the per-process random session id (also used as Parallel's + free-tier rate-limit token) picks which vendor goes first, so keyless + load spreads evenly across both free tiers fleet-wide while staying + stable within one process. The runner-up stays in the walk as a + fallback if the first isn't registered. Explicit user selection never + reaches this function — configured names resolve in step 1. + """ + try: + from plugins.web.keyless_mcp import _SESSION_ID + + if int(_SESSION_ID, 16) % 2: + return ("parallel", "exa") + except Exception as exc: # noqa: BLE001 — split is best-effort + logger.debug("keyless 50/50 split unavailable: %s", exc) + return _KEYLESS_PREFERENCE + + def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: """Resolve the active provider for a capability ("search" | "extract"). @@ -271,7 +294,7 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc # ``web.keyless_fallback: false``. This tier never pre-empts a keyed # setup: it is only reachable when the legacy walk found nothing. if _keyless_tier_enabled(): - for name in _KEYLESS_PREFERENCE: + for name in _keyless_preference(): provider = snapshot.get(name) if provider is None or not _capable(provider): continue diff --git a/tests/tools/test_web_keyless_fallback.py b/tests/tools/test_web_keyless_fallback.py index 88e0cf38fe..175a604e3f 100644 --- a/tests/tools/test_web_keyless_fallback.py +++ b/tests/tools/test_web_keyless_fallback.py @@ -275,11 +275,27 @@ class TestProviderRouting: class TestResolutionOrder: - def test_registry_falls_back_to_keyless_parallel(self, fresh_registry, monkeypatch): + def test_registry_falls_back_to_keyless(self, fresh_registry, monkeypatch): monkeypatch.setattr(registry, "_read_config_key", lambda *p: None) provider = registry.get_active_search_provider() assert provider is not None - assert provider.name == "parallel" # _KEYLESS_PREFERENCE order + # 50/50 split: either keyless vendor is valid; it must match the + # process-stable preference order. + assert provider.name == registry._keyless_preference()[0] + assert provider.name in ("exa", "parallel") + + def test_keyless_split_is_process_stable_and_covers_both(self, fresh_registry, monkeypatch): + monkeypatch.setattr(registry, "_read_config_key", lambda *p: None) + # Stable within a process: repeated resolution never flip-flops. + first = registry.get_active_search_provider().name + assert all( + registry.get_active_search_provider().name == first for _ in range(5) + ) + # Both split outcomes route correctly (simulate the two parities). + monkeypatch.setattr(keyless_mcp, "_SESSION_ID", "0" * 32) # even + assert registry._keyless_preference() == ("exa", "parallel") + monkeypatch.setattr(keyless_mcp, "_SESSION_ID", "1" * 32) # odd + assert registry._keyless_preference() == ("parallel", "exa") def test_registry_keyless_disabled_returns_none(self, fresh_registry, monkeypatch): monkeypatch.setattr(registry, "_read_config_key", lambda *p: None) @@ -298,14 +314,15 @@ class TestResolutionOrder: assert provider is not None and provider.name == "exa" def test_get_backend_keyless_last(self, monkeypatch): - # No creds at all -> keyless parallel. + # No creds at all -> a keyless vendor per the process-stable split. monkeypatch.setattr( web_tools, "_registered_web_provider", lambda name: {"parallel": ParallelWebSearchProvider(), "exa": ExaWebSearchProvider()}.get(name), ) monkeypatch.setattr(web_tools, "_list_registered_web_providers", list) - assert web_tools._get_backend() == "parallel" + from agent.web_search_registry import _keyless_preference + assert web_tools._get_backend() == _keyless_preference()[0] def test_get_backend_key_beats_keyless(self, monkeypatch): monkeypatch.setattr( diff --git a/tools/web_tools.py b/tools/web_tools.py index df68cf65d2..837276c68d 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -276,10 +276,10 @@ def _get_backend() -> str: # plugins yet (subprocess agent runs, delegate children, scripts). try: _ensure_web_plugins_loaded() - from agent.web_search_registry import _KEYLESS_PREFERENCE, _keyless_tier_enabled + from agent.web_search_registry import _keyless_preference, _keyless_tier_enabled if _keyless_tier_enabled(): - for name in _KEYLESS_PREFERENCE: + for name in _keyless_preference(): provider = _registered_web_provider(name) if provider is None: continue diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 4092e31e42..e4dc3f7517 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2219,7 +2219,7 @@ web: | **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | | **Exa** | `EXA_API_KEY` (optional — keyless free tier) | ✔ | ✔ | -**Backend selection:** If `web.backend` is not set, the backend is auto-detected from available API keys. If only `SEARXNG_URL` is set, SearXNG is used. If only `EXA_API_KEY` is set, Exa is used. If only `TAVILY_API_KEY` is set, Tavily is used. If only `PARALLEL_API_KEY` is set, Parallel is used. With **no credentials at all**, Hermes falls back to Parallel's (then Exa's) keyless free tier so web tools work on a fresh install — see the [Web Search guide](/user-guide/features/web-search) for details and limits. +**Backend selection:** If `web.backend` is not set, the backend is auto-detected from available API keys. If only `SEARXNG_URL` is set, SearXNG is used. If only `EXA_API_KEY` is set, Exa is used. If only `TAVILY_API_KEY` is set, Tavily is used. If only `PARALLEL_API_KEY` is set, Parallel is used. With **no credentials at all**, Hermes falls back to the Exa/Parallel keyless free tier (unpinned installs split 50/50 between the vendors) so web tools work on a fresh install — see the [Web Search guide](/user-guide/features/web-search) for details and limits. **SearXNG** is a free, self-hosted, privacy-respecting metasearch engine that queries 70+ search engines. No API key needed — just set `SEARXNG_URL` to your instance (e.g., `http://localhost:8080`). SearXNG is search-only; `web_extract` requires a separate extract provider (set `web.extract_backend`). See the [Web Search setup guide](/user-guide/features/web-search) for Docker setup instructions. diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index 863c8d672c..602d102493 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -32,7 +32,7 @@ Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecr **Per-capability split:** you can use different providers for search and extract independently — for example SearXNG (free) for search and Firecrawl for extract. See [Per-capability configuration](#per-capability-configuration) below. :::info Works out of the box — keyless free tier -A fresh install with **no web credentials at all** still gets working `web_search` and `web_extract`: Hermes falls back to Parallel's and Exa's public anonymous endpoints (rate-limited free tiers, Parallel first). No signup, no key. This tier is strictly last-resort — any configured backend or present API key always wins — and requests carry no user identifiers (only a random per-process session id, rotated on restart). For reliable, unthrottled service, set up a keyed provider. Disable the keyless tier entirely with `web.keyless_fallback: false`. +A fresh install with **no web credentials at all** still gets working `web_search` and `web_extract`: Hermes falls back to Exa's and Parallel's public anonymous endpoints (rate-limited free tiers), splitting unpinned installs 50/50 between the two vendors — the pick is random per process and stable within it. No signup, no key. This tier is strictly last-resort — any configured backend or present API key always wins — and requests carry no user identifiers (only a random per-process session id, rotated on restart). For reliable, unthrottled service, set up a keyed provider. Disable the keyless tier entirely with `web.keyless_fallback: false`. ::: **Choosing free vs paid explicitly:** in `hermes tools`, Exa and Parallel each appear as two rows — **Free (keyless)** and **Paid (API key)**. Picking Free pins the anonymous endpoint (even if you later add a key); picking Paid pins the keyed SDK path (a missing key then errors instead of silently downgrading to the free tier). The selection is stored as `web.provider_tier.: free|paid`; leave it unset for auto (key present → paid, otherwise free). @@ -366,9 +366,9 @@ If no backend is explicitly configured, Hermes picks the first available one bas | `SEARXNG_URL` | searxng | | `BRAVE_SEARCH_API_KEY` | brave-free | | `ddgs` package importable | ddgs | -| *(nothing set at all)* | parallel → exa keyless free tier | +| *(nothing set at all)* | exa / parallel keyless free tier (50/50 split) | -**Keyless free tier:** when *no* credential above is present, Hermes falls back to Parallel's public anonymous endpoint (then Exa's) so web tools work on a fresh install with zero setup. Both free tiers are rate-limited by the vendors under burst load; in practice sustained normal usage goes through fine. On throttling, the tool returns an error suggesting the matching API key. Set `web.keyless_fallback: false` to turn this tier off — with it off and no credentials, web tools are unavailable until a provider is configured. +**Keyless free tier:** when *no* credential above is present, Hermes falls back to Exa's and Parallel's public anonymous endpoints so web tools work on a fresh install with zero setup — unpinned installs split 50/50 between the two vendors (random per process, stable within it); pick one explicitly in `hermes tools` to pin it. Both free tiers are rate-limited by the vendors under burst load; in practice sustained normal usage goes through fine. On throttling, the tool returns an error suggesting the matching API key. Set `web.keyless_fallback: false` to turn this tier off — with it off and no credentials, web tools are unavailable until a provider is configured. xAI Web Search is **not** in the auto-detection chain — having `XAI_API_KEY` set (or being signed in via xAI Grok OAuth) does not automatically route web traffic through xAI, since those credentials are also used for inference / TTS / image gen and the user may want a different backend for web. Opt in explicitly with `web.backend: "xai"`.