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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user