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.
This commit is contained in:
Teknium
2026-08-19 16:22:30 -07:00
parent 08b7fad3a5
commit 4d87290d39
5 changed files with 55 additions and 15 deletions
+28 -5
View File
@@ -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.<capability>_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
+21 -4
View File
@@ -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(
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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.<name>: 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"`.