From 96c2fd3c04214ddeed4cb0c412dd0e04a516cc22 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:15:18 -0700
Subject: [PATCH 1/6] feat: web search/extract now work keyless on fresh
installs via Parallel + Exa free tiers
With zero web credentials configured, web_search/web_extract previously
resolved to the nonfunctional firecrawl sentinel and errored. Now the
backend resolution walks a strictly-last keyless tier: Parallel's and
Exa's public anonymous MCP endpoints (the same free tiers opencode ships
as its default search path).
- plugins/web/keyless_mcp.py: minimal JSON-RPC tools/call client for
mcp.exa.ai + search.parallel.ai (SSE + plain JSON parsing, typed
errors, per-process random session id, no user identifiers)
- WebSearchProvider.is_keyless_available(): separate weaker tier that
never leaks into is_available(), so keyed setups are never pre-empted
- Exa/Parallel providers: route to keyless endpoints when their key is
absent; keyed SDK path unchanged
- registry + _get_backend(): keyless walk (parallel -> exa) strictly
after every keyed/importable candidate; check_web_api_key() lights
the tools up on zero-credential installs
- web.keyless_fallback config key (default true) to disable the tier
- docs: web-search.md + configuration.md
E2E-verified against both live endpoints from an isolated HERMES_HOME
(search + extract via the real dispatchers, disable-flag negative path).
---
agent/web_search_provider.py | 16 +
agent/web_search_registry.py | 41 ++
hermes_cli/config_defaults.py | 5 +
plugins/web/exa/provider.py | 52 ++-
plugins/web/keyless_mcp.py | 377 ++++++++++++++++++
plugins/web/parallel/provider.py | 56 ++-
tests/tools/test_web_keyless_fallback.py | 300 ++++++++++++++
tests/tools/test_web_providers.py | 6 +
tests/tools/test_web_providers_searxng.py | 4 +
tools/web_tools.py | 34 +-
website/docs/user-guide/configuration.md | 11 +-
.../docs/user-guide/features/web-search.md | 11 +-
12 files changed, 899 insertions(+), 14 deletions(-)
create mode 100644 plugins/web/keyless_mcp.py
create mode 100644 tests/tools/test_web_keyless_fallback.py
diff --git a/agent/web_search_provider.py b/agent/web_search_provider.py
index e0f7ea1f1d..0f7f706c86 100644
--- a/agent/web_search_provider.py
+++ b/agent/web_search_provider.py
@@ -126,6 +126,22 @@ class WebSearchProvider(abc.ABC):
"""Return True if this provider implements :meth:`search`."""
return True
+ def is_keyless_available(self) -> bool:
+ """Return True when this provider can serve calls WITHOUT credentials.
+
+ A separate, weaker tier than :meth:`is_available`: providers with a
+ public anonymous free tier (Exa / Parallel MCP endpoints) return
+ True here so the registry can fall back to them when NO provider is
+ configured or keyed — and only then. Keyless availability must never
+ make :meth:`is_available` return True, or the legacy preference walk
+ would route users with real credentials for a lower-priority backend
+ onto the free tier of a higher-priority one.
+
+ Like :meth:`is_available`, this must be cheap and must NOT make
+ network calls. Default: False.
+ """
+ return False
+
def supports_extract(self) -> bool:
"""Return True if this provider implements :meth:`extract`.
diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py
index 2e0c116ec0..b78442cc50 100644
--- a/agent/web_search_registry.py
+++ b/agent/web_search_registry.py
@@ -166,6 +166,17 @@ _LEGACY_PREFERENCE = (
"ddgs",
)
+# 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``.
+_KEYLESS_PREFERENCE = (
+ "parallel",
+ "exa",
+)
+
def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]:
"""Resolve the active provider for a capability ("search" | "extract").
@@ -254,9 +265,39 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
):
return provider
+ # 4. Keyless free-tier walk — the user has NO credentialed/importable
+ # backend at all. Fall back to providers that can serve anonymously
+ # (public MCP free tiers), unless disabled via
+ # ``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:
+ provider = snapshot.get(name)
+ if provider is None or not _capable(provider):
+ continue
+ try:
+ if provider.is_keyless_available():
+ return provider
+ except Exception as exc: # noqa: BLE001 — buggy provider skipped
+ logger.debug(
+ "provider %s.is_keyless_available() raised %s", name, exc
+ )
+
return None
+def _keyless_tier_enabled() -> bool:
+ """Read ``web.keyless_fallback`` from config.yaml (default: enabled)."""
+ try:
+ from hermes_cli.config import load_config
+
+ web_cfg = load_config().get("web") or {}
+ return bool(web_cfg.get("keyless_fallback", True))
+ except Exception as exc: # noqa: BLE001 — config layer optional
+ logger.debug("keyless_fallback config read failed: %s", exc)
+ return True
+
+
def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]:
"""Return the plugin key of a *disabled* bundled web plugin that would
have provided the configured backend, or None.
diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py
index e55b3d923d..9bdd349a82 100644
--- a/hermes_cli/config_defaults.py
+++ b/hermes_cli/config_defaults.py
@@ -468,6 +468,11 @@ DEFAULT_CONFIG = {
"search_backend": "", # per-capability override for web_search (e.g. "searxng")
"extract_backend": "", # per-capability override for web_extract (e.g. "native")
"extract_char_limit": 15000, # per-page char budget for web_extract; larger pages truncate + store full text in cache/web
+ # Keyless free-tier fallback: with NO web backend configured or keyed,
+ # web_search/web_extract fall back to Parallel's / Exa's public
+ # anonymous MCP endpoints (rate-limited free tiers). Never pre-empts
+ # a configured or keyed backend. Set false to disable entirely.
+ "keyless_fallback": True,
},
"browser": {
diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py
index 17ce665dc1..5fdafdcef4 100644
--- a/plugins/web/exa/provider.py
+++ b/plugins/web/exa/provider.py
@@ -101,11 +101,23 @@ class ExaWebSearchProvider(WebSearchProvider):
return "Exa"
def is_available(self) -> bool:
- """Return True when ``EXA_API_KEY`` is set to a non-empty value."""
+ """Return True when ``EXA_API_KEY`` is set to a non-empty value.
+
+ Deliberately does NOT consider the keyless free tier — that would
+ let the legacy preference walk route keyed users of lower-priority
+ backends onto Exa's anonymous tier. Keyless availability is a
+ separate, last-resort signal (:meth:`is_keyless_available`).
+ """
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("EXA_API_KEY"))
+ def is_keyless_available(self) -> bool:
+ """Exa serves anonymous free-tier calls via its public MCP endpoint."""
+ from plugins.web.keyless_mcp import keyless_enabled
+
+ return keyless_enabled()
+
def supports_search(self) -> bool:
return True
@@ -125,6 +137,21 @@ class ExaWebSearchProvider(WebSearchProvider):
if is_interrupted():
return {"success": False, "error": "Interrupted"}
+ from agent.web_search_provider import get_provider_env
+
+ if not get_provider_env("EXA_API_KEY"):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ from plugins.web.keyless_mcp import (
+ exa_search_keyless,
+ keyless_enabled,
+ )
+
+ if keyless_enabled():
+ logger.info(
+ "Exa keyless search: '%s' (limit=%d)", query, limit
+ )
+ return exa_search_keyless(query, limit)
+
logger.info("Exa search: '%s' (limit=%d)", query, limit)
response = _get_exa_client().search(
query,
@@ -169,6 +196,19 @@ class ExaWebSearchProvider(WebSearchProvider):
{"url": u, "error": "Interrupted", "title": ""} for u in urls
]
+ from agent.web_search_provider import get_provider_env
+
+ if not get_provider_env("EXA_API_KEY"):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ from plugins.web.keyless_mcp import (
+ exa_extract_keyless,
+ keyless_enabled,
+ )
+
+ if keyless_enabled():
+ logger.info("Exa keyless extract: %d URL(s)", len(urls))
+ return exa_extract_keyless(list(urls))
+
logger.info("Exa extract: %d URL(s)", len(urls))
response = _get_exa_client().get_contents(urls, text=True)
@@ -204,12 +244,16 @@ class ExaWebSearchProvider(WebSearchProvider):
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Exa",
- "badge": "paid",
- "tag": "Semantic + neural web search with content extraction.",
+ "badge": "free tier · paid with key",
+ "tag": (
+ "Semantic + neural web search with content extraction. "
+ "Works keyless on Exa's free tier (per-IP rate limit); "
+ "add a key for reliable, unthrottled service."
+ ),
"env_vars": [
{
"key": "EXA_API_KEY",
- "prompt": "Exa API key",
+ "prompt": "Exa API key (optional — free tier works without one)",
"url": "https://exa.ai",
},
],
diff --git a/plugins/web/keyless_mcp.py b/plugins/web/keyless_mcp.py
new file mode 100644
index 0000000000..9309f42fcf
--- /dev/null
+++ b/plugins/web/keyless_mcp.py
@@ -0,0 +1,377 @@
+"""Keyless web search/extract via public MCP endpoints.
+
+Exa and Parallel both operate public, anonymous MCP endpoints with a free
+tier (the same endpoints the opencode CLI ships as its default search
+path):
+
+- Exa: https://mcp.exa.ai/mcp (tools: web_search_exa, web_fetch_exa)
+- Parallel: https://search.parallel.ai/mcp (tools: web_search, web_fetch)
+
+This module implements a minimal JSON-RPC ``tools/call`` client for those
+two endpoints so a fresh Hermes install with **zero web credentials** still
+gets working ``web_search`` / ``web_extract`` tools. The keyless tier is
+resolved strictly LAST — after every keyed backend, the managed tool
+gateway, ddgs, and custom plugin providers — so it never pre-empts a
+deliberate setup (see ``tools.web_tools._get_backend`` and the registry's
+``_KEYLESS_PREFERENCE`` walk).
+
+Privacy: requests carry no user identifiers. Parallel's free tier asks for
+a ``session_id`` used for rate limiting; we send a random per-process UUID
+(rotates every restart, never persisted). Their optional ``model_name``
+analytics field is deliberately omitted.
+
+Disable the whole tier with ``web.keyless_fallback: false`` in config.yaml.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+EXA_MCP_URL = "https://mcp.exa.ai/mcp"
+PARALLEL_MCP_URL = "https://search.parallel.ai/mcp"
+
+# Free-tier rate-limit correlation id for Parallel — random per process,
+# never persisted, not derived from any user/machine identifier.
+_SESSION_ID = uuid.uuid4().hex
+
+_TIMEOUT_SECONDS = 30
+
+
+class KeylessMCPError(RuntimeError):
+ """A keyless MCP call failed (transport, rate limit, or tool error)."""
+
+
+def keyless_enabled() -> bool:
+ """Return True when the keyless fallback tier is enabled.
+
+ Delegates to :func:`agent.web_search_registry._keyless_tier_enabled` so
+ the config chokepoint (``web.keyless_fallback``, default on) lives in
+ one place alongside the rest of backend resolution.
+ """
+ try:
+ from agent.web_search_registry import _keyless_tier_enabled
+
+ return _keyless_tier_enabled()
+ except Exception as exc: # noqa: BLE001 — resolver optional in stripped envs
+ logger.debug("keyless_enabled(): registry helper unavailable: %s", exc)
+ return True
+
+
+def _parse_mcp_body(body: str) -> str:
+ """Extract the first text content item from an MCP tools/call response.
+
+ Handles both plain-JSON bodies and SSE (``data: {...}`` lines) — the
+ Exa endpoint answers as an event stream, Parallel as direct JSON.
+ Raises :class:`KeylessMCPError` for JSON-RPC errors and ``isError``
+ tool results (e.g. Exa's free-tier rate-limit message).
+ """
+
+ def _from_payload(payload: str) -> Optional[str]:
+ payload = payload.strip()
+ if not payload.startswith("{"):
+ return None
+ data = json.loads(payload)
+ err = data.get("error")
+ if err:
+ raise KeylessMCPError(str(err.get("message") or err))
+ result = data.get("result") or {}
+ content = result.get("content") or []
+ if result.get("isError"):
+ texts = [c.get("text", "") for c in content if isinstance(c, dict)]
+ raise KeylessMCPError(
+ " ".join(t for t in texts if t) or "MCP tool call failed"
+ )
+ for item in content:
+ if isinstance(item, dict) and item.get("text"):
+ return str(item["text"])
+ return None
+
+ stripped = body.strip()
+ if stripped.startswith("{"):
+ try:
+ text = _from_payload(stripped)
+ if text is not None:
+ return text
+ except json.JSONDecodeError:
+ pass
+
+ for line in body.splitlines():
+ if not line.startswith("data: "):
+ continue
+ try:
+ text = _from_payload(line[len("data: "):])
+ except json.JSONDecodeError:
+ continue
+ if text is not None:
+ return text
+
+ raise KeylessMCPError("Unrecognized MCP response shape")
+
+
+def mcp_call(
+ url: str,
+ tool: str,
+ arguments: Dict[str, Any],
+ timeout: int = _TIMEOUT_SECONDS,
+) -> str:
+ """POST a JSON-RPC ``tools/call`` to *url* and return the text payload.
+
+ Raises :class:`KeylessMCPError` on transport failures, non-2xx
+ statuses, JSON-RPC errors, and error-shaped tool results.
+ """
+ import requests
+
+ payload = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {"name": tool, "arguments": arguments},
+ }
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/event-stream",
+ "User-Agent": "hermes-agent",
+ }
+ try:
+ response = requests.post(url, json=payload, headers=headers, timeout=timeout)
+ except requests.RequestException as exc:
+ raise KeylessMCPError(f"request failed: {exc}") from exc
+ if response.status_code >= 400:
+ raise KeylessMCPError(
+ f"HTTP {response.status_code}: {response.text[:300]}"
+ )
+ return _parse_mcp_body(response.text)
+
+
+# ---------------------------------------------------------------------------
+# Parallel (search.parallel.ai) — JSON text payloads
+# ---------------------------------------------------------------------------
+
+
+def parallel_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
+ """Keyless Parallel web search → legacy search response shape."""
+ try:
+ text = mcp_call(
+ PARALLEL_MCP_URL,
+ "web_search",
+ {
+ "objective": query,
+ "search_queries": [query],
+ "session_id": _SESSION_ID,
+ },
+ )
+ data = json.loads(text)
+ web_results = []
+ for i, result in enumerate(data.get("results") or []):
+ if limit and i >= limit:
+ break
+ excerpts = result.get("excerpts") or []
+ web_results.append(
+ {
+ "url": result.get("url") or "",
+ "title": result.get("title") or "",
+ "description": " ".join(excerpts) if excerpts else "",
+ "position": i + 1,
+ }
+ )
+ return {"success": True, "data": {"web": web_results}}
+ except KeylessMCPError as exc:
+ return {
+ "success": False,
+ "error": (
+ f"Keyless Parallel search failed: {exc}. "
+ "Set PARALLEL_API_KEY (https://parallel.ai) or another web "
+ "backend via `hermes tools` for reliable service."
+ ),
+ }
+ except (json.JSONDecodeError, TypeError, KeyError) as exc:
+ return {"success": False, "error": f"Keyless Parallel search returned an unexpected payload: {exc}"}
+
+
+def parallel_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
+ """Keyless Parallel web fetch → legacy extract result list."""
+ try:
+ text = mcp_call(
+ PARALLEL_MCP_URL,
+ "web_fetch",
+ {
+ "urls": list(urls),
+ "objective": "Full page content",
+ "session_id": _SESSION_ID,
+ },
+ )
+ data = json.loads(text)
+ except (KeylessMCPError, json.JSONDecodeError, TypeError) as exc:
+ message = (
+ f"Keyless Parallel extract failed: {exc}. "
+ "Set PARALLEL_API_KEY (https://parallel.ai) or another web "
+ "backend via `hermes tools` for reliable service."
+ )
+ return [
+ {"url": u, "title": "", "content": "", "error": message}
+ for u in urls
+ ]
+
+ results: List[Dict[str, Any]] = []
+ seen = set()
+ for result in data.get("results") or []:
+ url = result.get("url") or ""
+ title = result.get("title") or ""
+ content = (
+ result.get("full_content")
+ or result.get("content")
+ or "\n\n".join(result.get("excerpts") or [])
+ )
+ seen.add(url)
+ results.append(
+ {
+ "url": url,
+ "title": title,
+ "content": content,
+ "raw_content": content,
+ "metadata": {"sourceURL": url, "title": title},
+ }
+ )
+ for error in data.get("errors") or []:
+ url = error.get("url") or ""
+ seen.add(url)
+ results.append(
+ {
+ "url": url,
+ "title": "",
+ "content": "",
+ "error": str(
+ error.get("content") or error.get("error_type") or "extraction failed"
+ ),
+ "metadata": {"sourceURL": url},
+ }
+ )
+ # Any URL the endpoint silently dropped still gets an error entry so the
+ # caller's per-URL contract holds.
+ for u in urls:
+ if u not in seen:
+ results.append(
+ {"url": u, "title": "", "content": "", "error": "no content returned"}
+ )
+ return results
+
+
+# ---------------------------------------------------------------------------
+# Exa (mcp.exa.ai) — formatted plain-text payloads
+# ---------------------------------------------------------------------------
+
+
+def _parse_exa_search_text(text: str, limit: int) -> List[Dict[str, Any]]:
+ """Parse Exa's formatted search text into result dicts.
+
+ The payload is blocks separated by ``---`` lines, each shaped like::
+
+ Title:
+ URL:
+ Published: ...
+ Author: ...
+ Highlights:
+
+ """
+ results: List[Dict[str, Any]] = []
+ for block in text.split("\n---\n"):
+ title = ""
+ url = ""
+ highlight_lines: List[str] = []
+ in_highlights = False
+ for line in block.splitlines():
+ stripped = line.strip()
+ if stripped.startswith("Title:"):
+ title = stripped[len("Title:"):].strip()
+ in_highlights = False
+ elif stripped.startswith("URL:"):
+ url = stripped[len("URL:"):].strip()
+ in_highlights = False
+ elif stripped.startswith("Highlights:"):
+ in_highlights = True
+ elif stripped.startswith(("Published:", "Author:")):
+ in_highlights = False
+ elif in_highlights and stripped:
+ highlight_lines.append(stripped)
+ if url:
+ results.append(
+ {
+ "url": url,
+ "title": title,
+ "description": " ".join(highlight_lines),
+ "position": len(results) + 1,
+ }
+ )
+ if limit and len(results) >= limit:
+ break
+ return results
+
+
+def exa_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
+ """Keyless Exa web search → legacy search response shape."""
+ try:
+ text = mcp_call(
+ EXA_MCP_URL,
+ "web_search_exa",
+ {"query": query, "numResults": max(1, int(limit))},
+ )
+ except KeylessMCPError as exc:
+ return {
+ "success": False,
+ "error": (
+ f"Keyless Exa search failed: {exc}. "
+ "Set EXA_API_KEY (https://exa.ai) or another web backend "
+ "via `hermes tools` for reliable service."
+ ),
+ }
+ return {"success": True, "data": {"web": _parse_exa_search_text(text, limit)}}
+
+
+def exa_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
+ """Keyless Exa web fetch → legacy extract result list.
+
+ ``web_fetch_exa`` takes a ``urls`` array but returns one combined text
+ payload; we call it per-URL so each result maps cleanly.
+ """
+ results: List[Dict[str, Any]] = []
+ for url in urls:
+ try:
+ text = mcp_call(EXA_MCP_URL, "web_fetch_exa", {"urls": [url]})
+ except KeylessMCPError as exc:
+ results.append(
+ {
+ "url": url,
+ "title": "",
+ "content": "",
+ "error": (
+ f"Keyless Exa extract failed: {exc}. "
+ "Set EXA_API_KEY (https://exa.ai) or another web "
+ "backend via `hermes tools` for reliable service."
+ ),
+ }
+ )
+ continue
+ title = ""
+ for line in text.splitlines():
+ stripped = line.strip()
+ if stripped.startswith("# "):
+ title = stripped[2:].strip()
+ break
+ if stripped.startswith("Title:"):
+ title = stripped[len("Title:"):].strip()
+ break
+ results.append(
+ {
+ "url": url,
+ "title": title,
+ "content": text,
+ "raw_content": text,
+ "metadata": {"sourceURL": url, "title": title},
+ }
+ )
+ return results
diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py
index 028f5df3fc..a4211c7464 100644
--- a/plugins/web/parallel/provider.py
+++ b/plugins/web/parallel/provider.py
@@ -156,11 +156,23 @@ class ParallelWebSearchProvider(WebSearchProvider):
return "Parallel"
def is_available(self) -> bool:
- """Return True when ``PARALLEL_API_KEY`` is set to a non-empty value."""
+ """Return True when ``PARALLEL_API_KEY`` is set to a non-empty value.
+
+ Deliberately does NOT consider the keyless free tier — that would
+ let the legacy preference walk route keyed users of lower-priority
+ backends onto Parallel's anonymous tier. Keyless availability is a
+ separate, last-resort signal (:meth:`is_keyless_available`).
+ """
from agent.web_search_provider import get_provider_env
return bool(get_provider_env("PARALLEL_API_KEY"))
+ def is_keyless_available(self) -> bool:
+ """Parallel serves anonymous free-tier calls via its public MCP endpoint."""
+ from plugins.web.keyless_mcp import keyless_enabled
+
+ return keyless_enabled()
+
def supports_search(self) -> bool:
return True
@@ -180,6 +192,21 @@ class ParallelWebSearchProvider(WebSearchProvider):
if is_interrupted():
return {"success": False, "error": "Interrupted"}
+ from agent.web_search_provider import get_provider_env
+
+ if not get_provider_env("PARALLEL_API_KEY"):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ from plugins.web.keyless_mcp import (
+ keyless_enabled,
+ parallel_search_keyless,
+ )
+
+ if keyless_enabled():
+ logger.info(
+ "Parallel keyless search: '%s' (limit=%d)", query, limit
+ )
+ return parallel_search_keyless(query, limit)
+
mode = _resolve_search_mode()
logger.info(
"Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit
@@ -233,6 +260,23 @@ class ParallelWebSearchProvider(WebSearchProvider):
{"url": u, "error": "Interrupted", "title": ""} for u in urls
]
+ from agent.web_search_provider import get_provider_env
+
+ if not get_provider_env("PARALLEL_API_KEY"):
+ # Keyless free tier — blocking HTTP, so hop off the loop.
+ from plugins.web.keyless_mcp import (
+ keyless_enabled,
+ parallel_extract_keyless,
+ )
+
+ if keyless_enabled():
+ import asyncio
+
+ logger.info("Parallel keyless extract: %d URL(s)", len(urls))
+ return await asyncio.to_thread(
+ parallel_extract_keyless, list(urls)
+ )
+
logger.info("Parallel extract: %d URL(s)", len(urls))
response = await _get_async_client().beta.extract(
urls=urls,
@@ -285,12 +329,16 @@ class ParallelWebSearchProvider(WebSearchProvider):
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Parallel",
- "badge": "paid",
- "tag": "Objective-tuned search + parallel page extraction.",
+ "badge": "free tier · paid with key",
+ "tag": (
+ "Objective-tuned search + parallel page extraction. "
+ "Works keyless on Parallel's free tier; add a key for "
+ "reliable, unthrottled service."
+ ),
"env_vars": [
{
"key": "PARALLEL_API_KEY",
- "prompt": "Parallel API key",
+ "prompt": "Parallel API key (optional — free tier works without one)",
"url": "https://parallel.ai",
},
],
diff --git a/tests/tools/test_web_keyless_fallback.py b/tests/tools/test_web_keyless_fallback.py
new file mode 100644
index 0000000000..863489fe42
--- /dev/null
+++ b/tests/tools/test_web_keyless_fallback.py
@@ -0,0 +1,300 @@
+"""Keyless free-tier web search/extract fallback (Parallel + Exa MCP).
+
+Covers:
+- keyless_mcp response parsing (SSE + plain JSON, error shapes)
+- provider keyless routing: no key -> keyless path; key present -> SDK path
+- registry keyless walk: fires only when nothing is keyed; respects
+ web.keyless_fallback: false
+- _get_backend() keyless tier: strictly after every keyed candidate
+- check_web_api_key() lights up on a zero-credential install
+"""
+
+import json
+from unittest.mock import patch
+
+import pytest
+
+import tools.web_tools as web_tools
+from agent import web_search_registry as registry
+from plugins.web import keyless_mcp
+from plugins.web.exa.provider import ExaWebSearchProvider
+from plugins.web.parallel.provider import ParallelWebSearchProvider
+
+
+@pytest.fixture(autouse=True)
+def _no_web_env(monkeypatch):
+ """Blank every web credential and neutralize config lookups."""
+ for var in (
+ "EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY",
+ "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "BRAVE_SEARCH_API_KEY",
+ "SEARXNG_URL", "TOOL_GATEWAY_USER_TOKEN",
+ ):
+ monkeypatch.delenv(var, raising=False)
+ monkeypatch.setattr(
+ "agent.web_search_provider.get_provider_env", lambda name: "", raising=True
+ )
+ monkeypatch.setattr(web_tools, "_env_value", lambda name: "", raising=True)
+ monkeypatch.setattr(web_tools, "_load_web_config", dict, raising=True)
+ monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False, raising=True)
+ monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False, raising=True)
+ yield
+
+
+@pytest.fixture()
+def fresh_registry():
+ """Isolated registry snapshot with real exa/parallel providers."""
+ with registry._lock:
+ saved = dict(registry._providers)
+ saved_scoped = {k: dict(v) for k, v in registry._scoped_providers.items()}
+ registry._providers.clear()
+ registry._scoped_providers.clear()
+ registry.register_provider(ParallelWebSearchProvider())
+ registry.register_provider(ExaWebSearchProvider())
+ yield registry
+ with registry._lock:
+ registry._providers.clear()
+ registry._providers.update(saved)
+ registry._scoped_providers.clear()
+ registry._scoped_providers.update(saved_scoped)
+
+
+# ---------------------------------------------------------------------------
+# keyless_mcp parsing
+# ---------------------------------------------------------------------------
+
+
+class TestParseMcpBody:
+ def test_sse_body(self):
+ payload = {"result": {"content": [{"type": "text", "text": "hello"}]}}
+ body = f"event: message\ndata: {json.dumps(payload)}\n\n"
+ assert keyless_mcp._parse_mcp_body(body) == "hello"
+
+ def test_plain_json_body(self):
+ payload = {"result": {"content": [{"type": "text", "text": "hi"}]}}
+ assert keyless_mcp._parse_mcp_body(json.dumps(payload)) == "hi"
+
+ def test_jsonrpc_error_raises(self):
+ body = json.dumps({"error": {"code": -32000, "message": "rate limit"}})
+ with pytest.raises(keyless_mcp.KeylessMCPError, match="rate limit"):
+ keyless_mcp._parse_mcp_body(body)
+
+ def test_is_error_result_raises(self):
+ body = json.dumps(
+ {"result": {"isError": True, "content": [{"type": "text", "text": "boom"}]}}
+ )
+ with pytest.raises(keyless_mcp.KeylessMCPError, match="boom"):
+ keyless_mcp._parse_mcp_body(body)
+
+ def test_garbage_raises(self):
+ with pytest.raises(keyless_mcp.KeylessMCPError):
+ keyless_mcp._parse_mcp_body("nope")
+
+
+class TestExaTextParsing:
+ def test_parses_blocks(self):
+ text = (
+ "Title: First\nURL: https://a.example\nPublished: N/A\n"
+ "Highlights:\nsome highlight\nmore\n"
+ "\n---\n"
+ "Title: Second\nURL: https://b.example\nHighlights:\nother\n"
+ )
+ results = keyless_mcp._parse_exa_search_text(text, limit=5)
+ assert [r["url"] for r in results] == ["https://a.example", "https://b.example"]
+ assert results[0]["description"] == "some highlight more"
+ assert results[0]["position"] == 1
+
+ def test_limit_respected(self):
+ text = "\n---\n".join(
+ f"Title: T{i}\nURL: https://x{i}.example" for i in range(6)
+ )
+ assert len(keyless_mcp._parse_exa_search_text(text, limit=2)) == 2
+
+
+class TestKeylessCalls:
+ def test_parallel_search_shapes_results(self):
+ payload = json.dumps(
+ {
+ "results": [
+ {"url": "https://a", "title": "A", "excerpts": ["x", "y"]},
+ {"url": "https://b", "title": "B", "excerpts": []},
+ ]
+ }
+ )
+ with patch.object(keyless_mcp, "mcp_call", return_value=payload) as call:
+ out = keyless_mcp.parallel_search_keyless("query", limit=5)
+ assert out["success"] is True
+ assert out["data"]["web"][0] == {
+ "url": "https://a", "title": "A", "description": "x y", "position": 1,
+ }
+ args = call.call_args[0]
+ assert args[0] == keyless_mcp.PARALLEL_MCP_URL
+ assert args[1] == "web_search"
+ assert "model_name" not in args[2] # analytics field deliberately omitted
+
+ def test_parallel_search_failure_mentions_key_setup(self):
+ with patch.object(
+ keyless_mcp, "mcp_call", side_effect=keyless_mcp.KeylessMCPError("429")
+ ):
+ out = keyless_mcp.parallel_search_keyless("q")
+ assert out["success"] is False
+ assert "PARALLEL_API_KEY" in out["error"]
+
+ def test_parallel_extract_covers_missing_urls(self):
+ payload = json.dumps({"results": [{"url": "https://a", "title": "A", "excerpts": ["c"]}]})
+ with patch.object(keyless_mcp, "mcp_call", return_value=payload):
+ out = keyless_mcp.parallel_extract_keyless(["https://a", "https://gone"])
+ assert out[0]["content"] == "c"
+ assert out[1]["url"] == "https://gone"
+ assert "error" in out[1]
+
+ def test_exa_search_rate_limit_is_soft_error(self):
+ with patch.object(
+ keyless_mcp, "mcp_call",
+ side_effect=keyless_mcp.KeylessMCPError("free MCP rate limit"),
+ ):
+ out = keyless_mcp.exa_search_keyless("q")
+ assert out["success"] is False
+ assert "EXA_API_KEY" in out["error"]
+
+ def test_exa_extract_per_url(self):
+ with patch.object(
+ keyless_mcp, "mcp_call", return_value="# Page Title\nbody text"
+ ) as call:
+ out = keyless_mcp.exa_extract_keyless(["https://a", "https://b"])
+ assert call.call_count == 2
+ assert out[0]["title"] == "Page Title"
+ assert out[0]["content"].startswith("# Page Title")
+
+
+# ---------------------------------------------------------------------------
+# Provider routing: keyless vs keyed
+# ---------------------------------------------------------------------------
+
+
+class TestProviderRouting:
+ def test_parallel_keyless_path_when_no_key(self):
+ provider = ParallelWebSearchProvider()
+ with patch.object(
+ keyless_mcp, "parallel_search_keyless",
+ return_value={"success": True, "data": {"web": []}},
+ ) as keyless:
+ out = provider.search("q", limit=3)
+ assert out["success"] is True
+ keyless.assert_called_once_with("q", 3)
+
+ def test_exa_keyless_path_when_no_key(self):
+ provider = ExaWebSearchProvider()
+ with patch.object(
+ keyless_mcp, "exa_search_keyless",
+ return_value={"success": True, "data": {"web": []}},
+ ) as keyless:
+ out = provider.search("q", limit=3)
+ assert out["success"] is True
+ keyless.assert_called_once_with("q", 3)
+
+ def test_parallel_keyed_path_skips_keyless(self, monkeypatch):
+ monkeypatch.setattr(
+ "agent.web_search_provider.get_provider_env",
+ lambda name: "sk-real" if name == "PARALLEL_API_KEY" else "",
+ )
+ provider = ParallelWebSearchProvider()
+ with patch.object(keyless_mcp, "parallel_search_keyless") as keyless, \
+ patch("plugins.web.parallel.provider._get_sync_client") as client:
+ client.return_value.beta.search.return_value.results = []
+ out = provider.search("q")
+ keyless.assert_not_called()
+ assert out["success"] is True
+
+ def test_keyless_disabled_falls_through_to_key_error(self, monkeypatch):
+ monkeypatch.setattr(registry, "_keyless_tier_enabled", lambda: False)
+ provider = ParallelWebSearchProvider()
+ out = provider.search("q")
+ assert out["success"] is False
+ assert "PARALLEL_API_KEY" in out["error"]
+
+ def test_is_available_stays_false_keyless(self):
+ # Keyless tier must NOT leak into is_available() (legacy walk order).
+ assert ParallelWebSearchProvider().is_available() is False
+ assert ExaWebSearchProvider().is_available() is False
+ assert ParallelWebSearchProvider().is_keyless_available() is True
+ assert ExaWebSearchProvider().is_keyless_available() is True
+
+ @pytest.mark.asyncio
+ async def test_parallel_keyless_extract(self):
+ provider = ParallelWebSearchProvider()
+ with patch.object(
+ keyless_mcp, "parallel_extract_keyless",
+ return_value=[{"url": "https://a", "title": "", "content": "c"}],
+ ) as keyless:
+ out = await provider.extract(["https://a"])
+ assert out[0]["content"] == "c"
+ keyless.assert_called_once_with(["https://a"])
+
+
+# ---------------------------------------------------------------------------
+# Registry + _get_backend resolution order
+# ---------------------------------------------------------------------------
+
+
+class TestResolutionOrder:
+ def test_registry_falls_back_to_keyless_parallel(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
+
+ def test_registry_keyless_disabled_returns_none(self, fresh_registry, monkeypatch):
+ monkeypatch.setattr(registry, "_read_config_key", lambda *p: None)
+ monkeypatch.setattr(registry, "_keyless_tier_enabled", lambda: False)
+ assert registry.get_active_search_provider() is None
+
+ def test_keyed_provider_beats_keyless(self, fresh_registry, monkeypatch):
+ # Exa keyed, Parallel keyless: legacy walk must pick exa (keyed)
+ # even though parallel precedes exa in _KEYLESS_PREFERENCE.
+ monkeypatch.setattr(registry, "_read_config_key", lambda *p: None)
+ monkeypatch.setattr(
+ "agent.web_search_provider.get_provider_env",
+ lambda name: "sk-real" if name == "EXA_API_KEY" else "",
+ )
+ provider = registry.get_active_search_provider()
+ assert provider is not None and provider.name == "exa"
+
+ def test_get_backend_keyless_last(self, monkeypatch):
+ # No creds at all -> keyless parallel.
+ 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"
+
+ def test_get_backend_key_beats_keyless(self, monkeypatch):
+ monkeypatch.setattr(
+ web_tools, "_env_value",
+ lambda name: "sk-x" if name == "TAVILY_API_KEY" else "",
+ )
+ assert web_tools._get_backend() == "tavily"
+
+ def test_get_backend_keyless_disabled(self, monkeypatch):
+ monkeypatch.setattr(
+ web_tools, "_registered_web_provider",
+ lambda name: {"parallel": ParallelWebSearchProvider(),
+ "exa": ExaWebSearchProvider()}.get(name),
+ )
+ monkeypatch.setattr(web_tools, "_list_registered_web_providers", list)
+ monkeypatch.setattr(registry, "_keyless_tier_enabled", lambda: False)
+ assert web_tools._get_backend() == "firecrawl" # legacy sentinel
+
+ def test_check_web_api_key_true_on_keyless_install(self, fresh_registry, monkeypatch):
+ monkeypatch.setattr(registry, "_read_config_key", lambda *p: None)
+ monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None)
+ monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
+ assert web_tools.check_web_api_key() is True
+
+ def test_check_web_api_key_false_when_disabled(self, fresh_registry, monkeypatch):
+ monkeypatch.setattr(registry, "_read_config_key", lambda *p: None)
+ monkeypatch.setattr(registry, "_keyless_tier_enabled", lambda: False)
+ monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None)
+ monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
+ assert web_tools.check_web_api_key() is False
diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py
index 5cd3113143..731fc9af0b 100644
--- a/tests/tools/test_web_providers.py
+++ b/tests/tools/test_web_providers.py
@@ -193,8 +193,13 @@ class TestUnconfiguredErrorEnvelopeParity:
def test_unconfigured_search_emits_top_level_error(self, monkeypatch):
"""``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}``
— matching main's ``tool_error()`` envelope, not a per-result shape.
+
+ Keyless fallback (Parallel/Exa free tiers) is disabled here: with it
+ on, a zero-credential install routes to the keyless tier instead of
+ erroring (covered in test_web_keyless_fallback.py).
"""
from tools import web_tools
+ from agent import web_search_registry
self._clear_web_creds(monkeypatch)
# Reset firecrawl client cache so the unconfigured state is re-evaluated
@@ -202,6 +207,7 @@ class TestUnconfiguredErrorEnvelopeParity:
monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False)
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
+ monkeypatch.setattr(web_search_registry, "_keyless_tier_enabled", lambda: False)
result = json.loads(web_tools.web_search_tool("hello world", limit=3))
assert "error" in result, f"expected top-level 'error' key, got {result}"
diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py
index d8137423ae..be9d438c68 100644
--- a/tests/tools/test_web_providers_searxng.py
+++ b/tests/tools/test_web_providers_searxng.py
@@ -200,6 +200,7 @@ class TestCheckWebApiKey:
def test_no_credentials_fails(self, monkeypatch):
from tools import web_tools
+ from agent import web_search_registry
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
@@ -210,6 +211,9 @@ class TestCheckWebApiKey:
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
+ # Disable the keyless free tier — with it on, zero credentials still
+ # resolves (Parallel/Exa anonymous MCP; see test_web_keyless_fallback.py).
+ monkeypatch.setattr(web_search_registry, "_keyless_tier_enabled", lambda: False)
assert web_tools.check_web_api_key() is False
diff --git a/tools/web_tools.py b/tools/web_tools.py
index e8c62142af..df68cf65d2 100644
--- a/tools/web_tools.py
+++ b/tools/web_tools.py
@@ -267,6 +267,32 @@ def _get_backend() -> str:
except Exception as exc: # noqa: BLE001 — a broken provider is skipped
logger.debug("web provider %r.is_available() raised: %s", provider.name, exc)
+ # Keyless free-tier walk — zero credentials anywhere. Providers with a
+ # public anonymous endpoint (Parallel, Exa — see
+ # plugins/web/keyless_mcp.py) can still serve, unless the user disabled
+ # the tier via ``web.keyless_fallback: false``. Strictly last so it
+ # never pre-empts any keyed/importable backend above. Discovery must
+ # run first — this path is reachable from contexts that haven't loaded
+ # plugins yet (subprocess agent runs, delegate children, scripts).
+ try:
+ _ensure_web_plugins_loaded()
+ from agent.web_search_registry import _KEYLESS_PREFERENCE, _keyless_tier_enabled
+
+ if _keyless_tier_enabled():
+ for name in _KEYLESS_PREFERENCE:
+ provider = _registered_web_provider(name)
+ if provider is None:
+ continue
+ try:
+ if provider.is_keyless_available():
+ return name
+ except Exception as exc: # noqa: BLE001 — skip broken provider
+ logger.debug(
+ "web provider %r.is_keyless_available() raised: %s", name, exc
+ )
+ except Exception as exc: # noqa: BLE001 — registry optional; never fatal
+ logger.debug("keyless fallback walk failed: %s", exc)
+
return "firecrawl" # default (backward compat)
@@ -1075,8 +1101,14 @@ def check_web_api_key() -> bool:
# Any plugin-registered provider the registry considers active for either
# capability. Delegating to the registry's own availability-filtered
# resolvers keeps a single authority for "is a custom provider usable"
- # rather than re-implementing the walk here.
+ # rather than re-implementing the walk here. This also covers the
+ # keyless free tier (Parallel/Exa anonymous MCP endpoints): the registry
+ # walk falls back to keyless-capable providers when nothing is keyed,
+ # so a zero-credential install still lights the web tools up. Discovery
+ # must run first — check_fn fires at tool-registration time, before any
+ # dispatch has populated the registry.
try:
+ _ensure_web_plugins_loaded()
from agent.web_search_registry import (
get_active_search_provider,
get_active_extract_provider,
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index 0eccf2d5aa..ee815a250f 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -2197,17 +2197,22 @@ web:
# Or use per-capability keys to mix providers (e.g. free search + paid extract):
search_backend: "searxng"
extract_backend: "firecrawl"
+
+ # Keyless free-tier fallback (default: true). With no backend configured
+ # and no API keys present, web tools fall back to Parallel's / Exa's
+ # public anonymous endpoints (rate-limited). Set false to disable.
+ keyless_fallback: true
```
| Backend | Env Var | Search | Extract |
|---------|---------|--------|---------|
| **Firecrawl** (default) | `FIRECRAWL_API_KEY` | ✔ | ✔ |
| **SearXNG** | `SEARXNG_URL` | ✔ | — |
-| **Parallel** | `PARALLEL_API_KEY` | ✔ | ✔ |
+| **Parallel** | `PARALLEL_API_KEY` (optional — keyless free tier) | ✔ | ✔ |
| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ |
-| **Exa** | `EXA_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. Otherwise Firecrawl is the default.
+**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.
**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 ca7f529bbc..4525a3ed85 100644
--- a/website/docs/user-guide/features/web-search.md
+++ b/website/docs/user-guide/features/web-search.md
@@ -23,14 +23,18 @@ Both are configured through a single backend selection. Providers are chosen via
| **Brave Search (free tier)** | `BRAVE_SEARCH_API_KEY` | ✔ | — | 2 000 queries/mo |
| **DDGS (DuckDuckGo)** | — (no key) | ✔ | — | ✔ Free |
| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | 1 000 searches/mo |
-| **Exa** | `EXA_API_KEY` | ✔ | ✔ | 1 000 searches/mo |
-| **Parallel** | `PARALLEL_API_KEY` | ✔ | ✔ | Paid |
+| **Exa** | `EXA_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier (rate-limited) · 1 000 searches/mo with key |
+| **Parallel** | `PARALLEL_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier (rate-limited) · paid with key |
| **xAI (Grok)** | `XAI_API_KEY` or `hermes auth add xai-oauth` | ✔ | — | Paid (SuperGrok or per-token) |
Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecrawl/Tavily/Exa/Parallel when you also need `web_extract`. DDGS uses the [`ddgs` Python package](https://pypi.org/project/ddgs/) under the hood; if it isn't already installed, run `pip install ddgs` (or let Hermes lazy-install it on first use). xAI runs Grok's server-side `web_search` tool on the Responses API — results are LLM-generated rather than index-backed, so titles, descriptions, and URL choice are all model output (see the [trust-model caveat](#xai-grok) below).
**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`.
+:::
+
:::tip Nous Subscribers
If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, web search and extract are available through the **[Tool Gateway](tool-gateway.md)** via managed Firecrawl — no API key needed. New installs can run `hermes setup --portal` to log in and turn on all gateway tools at once; existing installs can flip just web via `hermes tools`.
:::
@@ -360,6 +364,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 |
+
+**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. These free tiers are rate-limited by the vendors (Exa's per-IP limit is fairly tight); 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"`.
From 2d9dad0bae578f0b7f2c5b98d8819f163ddf8bf2 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:24:27 -0700
Subject: [PATCH 2/6] docs: correct Exa keyless rate-limit characterization
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 12-request sequential burst from the same IP that earlier saw the
free-tier rate-limit error went 12/12 OK — the limit is a transient
burst/load control, not a tight standing per-IP quota. Soften the docs
and setup-schema wording accordingly (opencode users hit Exa keyless
as their default path in practice without throttling).
---
plugins/web/exa/provider.py | 4 ++--
website/docs/user-guide/features/web-search.md | 6 +++---
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py
index 5fdafdcef4..4f6b6d4601 100644
--- a/plugins/web/exa/provider.py
+++ b/plugins/web/exa/provider.py
@@ -247,8 +247,8 @@ class ExaWebSearchProvider(WebSearchProvider):
"badge": "free tier · paid with key",
"tag": (
"Semantic + neural web search with content extraction. "
- "Works keyless on Exa's free tier (per-IP rate limit); "
- "add a key for reliable, unthrottled service."
+ "Works keyless on Exa's free tier; add a key for "
+ "unthrottled, guaranteed service."
),
"env_vars": [
{
diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md
index 4525a3ed85..286895a198 100644
--- a/website/docs/user-guide/features/web-search.md
+++ b/website/docs/user-guide/features/web-search.md
@@ -23,8 +23,8 @@ Both are configured through a single backend selection. Providers are chosen via
| **Brave Search (free tier)** | `BRAVE_SEARCH_API_KEY` | ✔ | — | 2 000 queries/mo |
| **DDGS (DuckDuckGo)** | — (no key) | ✔ | — | ✔ Free |
| **Tavily** | `TAVILY_API_KEY` | ✔ | ✔ | 1 000 searches/mo |
-| **Exa** | `EXA_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier (rate-limited) · 1 000 searches/mo with key |
-| **Parallel** | `PARALLEL_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier (rate-limited) · paid with key |
+| **Exa** | `EXA_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier · 1 000 searches/mo with key |
+| **Parallel** | `PARALLEL_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless free tier · paid with key |
| **xAI (Grok)** | `XAI_API_KEY` or `hermes auth add xai-oauth` | ✔ | — | Paid (SuperGrok or per-token) |
Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecrawl/Tavily/Exa/Parallel when you also need `web_extract`. DDGS uses the [`ddgs` Python package](https://pypi.org/project/ddgs/) under the hood; if it isn't already installed, run `pip install ddgs` (or let Hermes lazy-install it on first use). xAI runs Grok's server-side `web_search` tool on the Responses API — results are LLM-generated rather than index-backed, so titles, descriptions, and URL choice are all model output (see the [trust-model caveat](#xai-grok) below).
@@ -366,7 +366,7 @@ If no backend is explicitly configured, Hermes picks the first available one bas
| `ddgs` package importable | ddgs |
| *(nothing set at all)* | parallel → exa keyless free tier |
-**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. These free tiers are rate-limited by the vendors (Exa's per-IP limit is fairly tight); 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 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.
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"`.
From f08d3e400ffa38b6e3c38542ca8a34c3aa074a69 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:36:20 -0700
Subject: [PATCH 3/6] feat: hermes tools lets Exa/Parallel users pick the free
keyless or paid keyed endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Exa and Parallel now each render as two picker rows in hermes tools —
'Free (keyless)' and 'Paid (API key)'. Selection persists to
web.provider_tier.:
- free: always the anonymous public endpoint, even with a key set
- paid: always the keyed SDK path; missing key errors instead of
silently downgrading to the free tier (is_keyless_available also
returns False so the auto-fallback walk can't route there)
- unset: auto (key present -> paid, else keyless)
Mechanism: get_setup_schema() gains a 'variants' list the picker
flattens into sibling rows sharing one web_backend; selection writes
the tier via both _write_provider_config sites; active-row detection
matches the tier (auto mirrors use_keyless). Routing goes through a
single use_keyless() chokepoint shared by search+extract in both
providers.
Live E2E: tier=free with a fake key present searched keyless OK (a
keyed call would have 401'd); tier=paid without key errored naming
PARALLEL_API_KEY; picker rows verified for both vendors x both tiers.
---
hermes_cli/config_defaults.py | 7 ++
hermes_cli/tools_config.py | 105 +++++++++++++++---
plugins/web/exa/provider.py | 72 ++++++------
plugins/web/keyless_mcp.py | 43 +++++++
plugins/web/parallel/provider.py | 78 +++++++------
tests/tools/test_web_keyless_fallback.py | 99 +++++++++++++++++
tests/tools/test_web_tools_config.py | 10 +-
website/docs/user-guide/configuration.md | 7 ++
.../docs/user-guide/features/web-search.md | 2 +
9 files changed, 339 insertions(+), 84 deletions(-)
diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py
index 9bdd349a82..bf8c5f9b98 100644
--- a/hermes_cli/config_defaults.py
+++ b/hermes_cli/config_defaults.py
@@ -473,6 +473,13 @@ DEFAULT_CONFIG = {
# anonymous MCP endpoints (rate-limited free tiers). Never pre-empts
# a configured or keyed backend. Set false to disable entirely.
"keyless_fallback": True,
+ # Per-provider tier selection for providers with both a keyless free
+ # endpoint and a keyed paid SDK path (exa, parallel). Set by the
+ # `hermes tools` picker's "Free (keyless)" / "Paid (API key)" rows.
+ # free — always use the anonymous free endpoint (even with a key)
+ # paid — always use the keyed SDK path (missing key = error)
+ # unset — auto: keyed when the API key is present, else keyless
+ "provider_tier": {},
},
"browser": {
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 599ec3abf1..c829c55c35 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -3129,18 +3129,29 @@ def _plugin_web_search_providers() -> list[dict]:
continue
if not isinstance(schema, dict):
continue
- row = {
- "name": schema.get("name", provider.display_name),
- "badge": schema.get("badge", ""),
- "tag": schema.get("tag", ""),
- "env_vars": schema.get("env_vars", []),
- "web_backend": name,
- "web_search_plugin_name": name,
- }
- # Optional pass-through fields the schema can opt into.
- if schema.get("post_setup"):
- row["post_setup"] = schema["post_setup"]
- rows.append(row)
+ # A schema may expose tier ``variants`` (e.g. Exa/Parallel free
+ # keyless endpoint vs paid SDK) — flatten the base row plus each
+ # variant into separate picker rows sharing the same backend name,
+ # distinguished by ``web_tier`` (persisted to
+ # ``web.provider_tier.`` on selection).
+ schemas = [schema] + [
+ v for v in (schema.get("variants") or []) if isinstance(v, dict)
+ ]
+ for entry in schemas:
+ row = {
+ "name": entry.get("name", provider.display_name),
+ "badge": entry.get("badge", ""),
+ "tag": entry.get("tag", ""),
+ "env_vars": entry.get("env_vars", []),
+ "web_backend": name,
+ "web_search_plugin_name": name,
+ }
+ if entry.get("web_tier"):
+ row["web_tier"] = entry["web_tier"]
+ # Optional pass-through fields the schema can opt into.
+ if entry.get("post_setup"):
+ row["post_setup"] = entry["post_setup"]
+ rows.append(row)
return rows
@@ -3788,6 +3799,44 @@ def _configure_tool_category(
_configure_provider(providers[provider_idx], config, force_fresh=force_fresh)
+def _web_tier_matches(provider: dict, config: dict) -> bool:
+ """Return True when a web picker row's tier matches the configured tier.
+
+ Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` name
+ and differ only in ``web_tier``. The configured tier lives at
+ ``web.provider_tier.`` (set on selection). Matching rules:
+
+ - row has no ``web_tier`` → tier-agnostic row, matches (legacy rows)
+ - configured tier set → must equal the row's tier
+ - configured tier unset → "auto": the effective tier is paid when the
+ row's env vars are all present, free otherwise — highlight the row
+ the runtime would actually use
+ """
+ row_tier = provider.get("web_tier")
+ if not row_tier:
+ return True
+ web_cfg = config.get("web")
+ if not isinstance(web_cfg, dict):
+ web_cfg = {}
+ tiers = web_cfg.get("provider_tier")
+ if not isinstance(tiers, dict):
+ tiers = {}
+ configured = str(tiers.get(provider["web_backend"], "") or "").lower().strip()
+ if configured in ("free", "paid"):
+ return configured == row_tier
+ # Auto: mirror plugins.web.keyless_mcp.use_keyless — key present → paid.
+ try:
+ from agent.web_search_provider import get_provider_env
+
+ key_var = {"exa": "EXA_API_KEY", "parallel": "PARALLEL_API_KEY"}.get(
+ provider["web_backend"]
+ )
+ has_key = bool(get_provider_env(key_var)) if key_var else False
+ except Exception:
+ has_key = False
+ return row_tier == ("paid" if has_key else "free")
+
+
def _is_provider_active(
provider: dict,
config: dict,
@@ -3855,7 +3904,11 @@ def _is_provider_active(
return feature.managed_by_nous and provider["browser_provider"] == current
if provider.get("web_backend"):
current = cfg_get(config, "web", "backend")
- return feature.managed_by_nous and current == provider["web_backend"]
+ return (
+ feature.managed_by_nous
+ and current == provider["web_backend"]
+ and _web_tier_matches(provider, config)
+ )
return feature.managed_by_nous
if provider.get("tts_provider"):
@@ -3903,7 +3956,9 @@ def _is_provider_active(
return False
if provider.get("web_backend"):
current = cfg_get(config, "web", "backend")
- return current == provider["web_backend"]
+ if current != provider["web_backend"]:
+ return False
+ return _web_tier_matches(provider, config)
if provider.get("computer_use_backend"):
current = cfg_get(config, "computer_use", "backend")
return current == provider["computer_use_backend"]
@@ -4375,6 +4430,14 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) ->
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
+ if provider.get("web_tier"):
+ tiers = web_cfg.setdefault("provider_tier", {})
+ if isinstance(tiers, dict):
+ tiers[provider["web_backend"]] = provider["web_tier"]
+ else:
+ stale_tiers = web_cfg.get("provider_tier")
+ if isinstance(stale_tiers, dict):
+ stale_tiers.pop(provider["web_backend"], None)
# Set computer_use backend in config if applicable
if provider.get("computer_use_backend"):
@@ -5047,7 +5110,19 @@ def _reconfigure_provider(
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
- _print_success(f" Web backend set to: {provider['web_backend']}")
+ if provider.get("web_tier"):
+ tiers = web_cfg.setdefault("provider_tier", {})
+ if isinstance(tiers, dict):
+ tiers[provider["web_backend"]] = provider["web_tier"]
+ _print_success(
+ f" Web backend set to: {provider['web_backend']} "
+ f"({provider['web_tier']} tier)"
+ )
+ else:
+ stale_tiers = web_cfg.get("provider_tier")
+ if isinstance(stale_tiers, dict):
+ stale_tiers.pop(provider["web_backend"], None)
+ _print_success(f" Web backend set to: {provider['web_backend']}")
# Set computer_use backend in config if applicable
if provider.get("computer_use_backend"):
diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py
index 4f6b6d4601..1f4dde6f16 100644
--- a/plugins/web/exa/provider.py
+++ b/plugins/web/exa/provider.py
@@ -113,10 +113,14 @@ class ExaWebSearchProvider(WebSearchProvider):
return bool(get_provider_env("EXA_API_KEY"))
def is_keyless_available(self) -> bool:
- """Exa serves anonymous free-tier calls via its public MCP endpoint."""
- from plugins.web.keyless_mcp import keyless_enabled
+ """Exa serves anonymous free-tier calls via its public MCP endpoint.
- return keyless_enabled()
+ False when the user forced ``web.provider_tier.exa: paid`` — an
+ explicit paid selection must never silently resolve keyless.
+ """
+ from plugins.web.keyless_mcp import keyless_enabled, provider_tier
+
+ return keyless_enabled() and provider_tier("exa") != "paid"
def supports_search(self) -> bool:
return True
@@ -139,18 +143,14 @@ class ExaWebSearchProvider(WebSearchProvider):
from agent.web_search_provider import get_provider_env
- if not get_provider_env("EXA_API_KEY"):
- # Keyless free tier — public MCP endpoint, no SDK needed.
- from plugins.web.keyless_mcp import (
- exa_search_keyless,
- keyless_enabled,
- )
+ from plugins.web.keyless_mcp import exa_search_keyless, use_keyless
- if keyless_enabled():
- logger.info(
- "Exa keyless search: '%s' (limit=%d)", query, limit
- )
- return exa_search_keyless(query, limit)
+ if use_keyless("exa", get_provider_env("EXA_API_KEY")):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ logger.info(
+ "Exa keyless search: '%s' (limit=%d)", query, limit
+ )
+ return exa_search_keyless(query, limit)
logger.info("Exa search: '%s' (limit=%d)", query, limit)
response = _get_exa_client().search(
@@ -198,16 +198,12 @@ class ExaWebSearchProvider(WebSearchProvider):
from agent.web_search_provider import get_provider_env
- if not get_provider_env("EXA_API_KEY"):
- # Keyless free tier — public MCP endpoint, no SDK needed.
- from plugins.web.keyless_mcp import (
- exa_extract_keyless,
- keyless_enabled,
- )
+ from plugins.web.keyless_mcp import exa_extract_keyless, use_keyless
- if keyless_enabled():
- logger.info("Exa keyless extract: %d URL(s)", len(urls))
- return exa_extract_keyless(list(urls))
+ if use_keyless("exa", get_provider_env("EXA_API_KEY")):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ logger.info("Exa keyless extract: %d URL(s)", len(urls))
+ return exa_extract_keyless(list(urls))
logger.info("Exa extract: %d URL(s)", len(urls))
response = _get_exa_client().get_contents(urls, text=True)
@@ -243,18 +239,30 @@ class ExaWebSearchProvider(WebSearchProvider):
def get_setup_schema(self) -> Dict[str, Any]:
return {
- "name": "Exa",
- "badge": "free tier · paid with key",
+ "name": "Exa · Free (keyless)",
+ "badge": "free · no key",
"tag": (
- "Semantic + neural web search with content extraction. "
- "Works keyless on Exa's free tier; add a key for "
- "unthrottled, guaranteed service."
+ "Semantic + neural web search with content extraction on "
+ "Exa's anonymous free tier. Rate-limited under burst load."
),
- "env_vars": [
+ "env_vars": [],
+ "web_tier": "free",
+ "variants": [
{
- "key": "EXA_API_KEY",
- "prompt": "Exa API key (optional — free tier works without one)",
- "url": "https://exa.ai",
+ "name": "Exa · Paid (API key)",
+ "badge": "paid",
+ "tag": (
+ "Semantic + neural web search with content extraction "
+ "via the Exa SDK. Unthrottled, guaranteed service."
+ ),
+ "env_vars": [
+ {
+ "key": "EXA_API_KEY",
+ "prompt": "Exa API key",
+ "url": "https://exa.ai",
+ },
+ ],
+ "web_tier": "paid",
},
],
}
diff --git a/plugins/web/keyless_mcp.py b/plugins/web/keyless_mcp.py
index 9309f42fcf..df21069abc 100644
--- a/plugins/web/keyless_mcp.py
+++ b/plugins/web/keyless_mcp.py
@@ -62,6 +62,49 @@ def keyless_enabled() -> bool:
return True
+def provider_tier(name: str) -> str:
+ """Return the user-selected tier for *name*: ``free``, ``paid``, or ``auto``.
+
+ Reads ``web.provider_tier.`` from config.yaml (set by the
+ ``hermes tools`` picker's Free/Paid rows). ``free`` forces the keyless
+ public endpoint even when the vendor API key is present; ``paid``
+ forces the keyed SDK path (missing key surfaces the standard
+ "X_API_KEY not set" error instead of silently downgrading to the free
+ tier). Anything else — including unset — is ``auto``: key present →
+ keyed, otherwise keyless when the tier is enabled.
+ """
+ try:
+ from hermes_cli.config import load_config
+
+ web_cfg = load_config().get("web") or {}
+ tiers = web_cfg.get("provider_tier") or {}
+ value = str(tiers.get(name, "") or "").lower().strip()
+ return value if value in ("free", "paid") else "auto"
+ except Exception as exc: # noqa: BLE001 — config layer optional
+ logger.debug("provider_tier(%r) config read failed: %s", name, exc)
+ return "auto"
+
+
+def use_keyless(name: str, api_key: str) -> bool:
+ """Decide whether provider *name* should route via the keyless endpoint.
+
+ Single chokepoint shared by the Exa/Parallel search + extract paths so
+ tier semantics can't drift between capabilities:
+
+ - tier ``free`` → keyless, even when *api_key* is set
+ - tier ``paid`` → keyed, even when *api_key* is missing (the keyed
+ path then raises its usual missing-key error)
+ - tier ``auto`` → keyed when *api_key* is set; otherwise keyless when
+ ``web.keyless_fallback`` is enabled
+ """
+ tier = provider_tier(name)
+ if tier == "free":
+ return True
+ if tier == "paid":
+ return False
+ return not api_key and keyless_enabled()
+
+
def _parse_mcp_body(body: str) -> str:
"""Extract the first text content item from an MCP tools/call response.
diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py
index a4211c7464..4f0f05950c 100644
--- a/plugins/web/parallel/provider.py
+++ b/plugins/web/parallel/provider.py
@@ -168,10 +168,14 @@ class ParallelWebSearchProvider(WebSearchProvider):
return bool(get_provider_env("PARALLEL_API_KEY"))
def is_keyless_available(self) -> bool:
- """Parallel serves anonymous free-tier calls via its public MCP endpoint."""
- from plugins.web.keyless_mcp import keyless_enabled
+ """Parallel serves anonymous free-tier calls via its public MCP endpoint.
- return keyless_enabled()
+ False when the user forced ``web.provider_tier.parallel: paid`` —
+ an explicit paid selection must never silently resolve keyless.
+ """
+ from plugins.web.keyless_mcp import keyless_enabled, provider_tier
+
+ return keyless_enabled() and provider_tier("parallel") != "paid"
def supports_search(self) -> bool:
return True
@@ -194,18 +198,14 @@ class ParallelWebSearchProvider(WebSearchProvider):
from agent.web_search_provider import get_provider_env
- if not get_provider_env("PARALLEL_API_KEY"):
- # Keyless free tier — public MCP endpoint, no SDK needed.
- from plugins.web.keyless_mcp import (
- keyless_enabled,
- parallel_search_keyless,
- )
+ from plugins.web.keyless_mcp import parallel_search_keyless, use_keyless
- if keyless_enabled():
- logger.info(
- "Parallel keyless search: '%s' (limit=%d)", query, limit
- )
- return parallel_search_keyless(query, limit)
+ if use_keyless("parallel", get_provider_env("PARALLEL_API_KEY")):
+ # Keyless free tier — public MCP endpoint, no SDK needed.
+ logger.info(
+ "Parallel keyless search: '%s' (limit=%d)", query, limit
+ )
+ return parallel_search_keyless(query, limit)
mode = _resolve_search_mode()
logger.info(
@@ -262,21 +262,17 @@ class ParallelWebSearchProvider(WebSearchProvider):
from agent.web_search_provider import get_provider_env
- if not get_provider_env("PARALLEL_API_KEY"):
+ from plugins.web.keyless_mcp import parallel_extract_keyless, use_keyless
+
+ if use_keyless("parallel", get_provider_env("PARALLEL_API_KEY")):
# Keyless free tier — blocking HTTP, so hop off the loop.
- from plugins.web.keyless_mcp import (
- keyless_enabled,
- parallel_extract_keyless,
+ import asyncio
+
+ logger.info("Parallel keyless extract: %d URL(s)", len(urls))
+ return await asyncio.to_thread(
+ parallel_extract_keyless, list(urls)
)
- if keyless_enabled():
- import asyncio
-
- logger.info("Parallel keyless extract: %d URL(s)", len(urls))
- return await asyncio.to_thread(
- parallel_extract_keyless, list(urls)
- )
-
logger.info("Parallel extract: %d URL(s)", len(urls))
response = await _get_async_client().beta.extract(
urls=urls,
@@ -328,18 +324,30 @@ class ParallelWebSearchProvider(WebSearchProvider):
def get_setup_schema(self) -> Dict[str, Any]:
return {
- "name": "Parallel",
- "badge": "free tier · paid with key",
+ "name": "Parallel · Free (keyless)",
+ "badge": "free · no key",
"tag": (
- "Objective-tuned search + parallel page extraction. "
- "Works keyless on Parallel's free tier; add a key for "
- "reliable, unthrottled service."
+ "Objective-tuned search + page extraction on Parallel's "
+ "anonymous free tier. Rate-limited under burst load."
),
- "env_vars": [
+ "env_vars": [],
+ "web_tier": "free",
+ "variants": [
{
- "key": "PARALLEL_API_KEY",
- "prompt": "Parallel API key (optional — free tier works without one)",
- "url": "https://parallel.ai",
+ "name": "Parallel · Paid (API key)",
+ "badge": "paid",
+ "tag": (
+ "Objective-tuned search + parallel page extraction "
+ "via the Parallel SDK. Unthrottled, guaranteed service."
+ ),
+ "env_vars": [
+ {
+ "key": "PARALLEL_API_KEY",
+ "prompt": "Parallel API key",
+ "url": "https://parallel.ai",
+ },
+ ],
+ "web_tier": "paid",
},
],
}
diff --git a/tests/tools/test_web_keyless_fallback.py b/tests/tools/test_web_keyless_fallback.py
index 863489fe42..88e0cf38fe 100644
--- a/tests/tools/test_web_keyless_fallback.py
+++ b/tests/tools/test_web_keyless_fallback.py
@@ -219,6 +219,44 @@ class TestProviderRouting:
assert ParallelWebSearchProvider().is_keyless_available() is True
assert ExaWebSearchProvider().is_keyless_available() is True
+ def test_tier_free_forces_keyless_even_with_key(self, monkeypatch):
+ monkeypatch.setattr(
+ "agent.web_search_provider.get_provider_env",
+ lambda name: "sk-real" if name == "PARALLEL_API_KEY" else "",
+ )
+ monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "free")
+ provider = ParallelWebSearchProvider()
+ with patch.object(
+ keyless_mcp, "parallel_search_keyless",
+ return_value={"success": True, "data": {"web": []}},
+ ) as keyless:
+ out = provider.search("q")
+ keyless.assert_called_once()
+ assert out["success"] is True
+
+ def test_tier_paid_forces_keyed_without_key(self, monkeypatch):
+ monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "paid")
+ provider = ParallelWebSearchProvider()
+ with patch.object(keyless_mcp, "parallel_search_keyless") as keyless:
+ out = provider.search("q")
+ keyless.assert_not_called()
+ assert out["success"] is False
+ assert "PARALLEL_API_KEY" in out["error"]
+
+ def test_tier_paid_disables_keyless_availability(self, monkeypatch):
+ monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "paid")
+ assert ParallelWebSearchProvider().is_keyless_available() is False
+ assert ExaWebSearchProvider().is_keyless_available() is False
+
+ def test_provider_tier_reads_config(self, monkeypatch):
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"web": {"provider_tier": {"exa": "FREE", "parallel": "bogus"}}},
+ )
+ assert keyless_mcp.provider_tier("exa") == "free"
+ assert keyless_mcp.provider_tier("parallel") == "auto" # invalid → auto
+ assert keyless_mcp.provider_tier("tavily") == "auto" # unset → auto
+
@pytest.mark.asyncio
async def test_parallel_keyless_extract(self):
provider = ParallelWebSearchProvider()
@@ -298,3 +336,64 @@ class TestResolutionOrder:
monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None)
monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
assert web_tools.check_web_api_key() is False
+
+
+# ---------------------------------------------------------------------------
+# hermes tools picker: tier variant rows
+# ---------------------------------------------------------------------------
+
+
+class TestPickerTierRows:
+ def test_variant_schemas_flatten_to_tier_rows(self, fresh_registry, monkeypatch):
+ from hermes_cli import tools_config
+
+ monkeypatch.setattr(
+ "hermes_cli.plugins._ensure_plugins_discovered", lambda: None
+ )
+ rows = tools_config._plugin_web_search_providers()
+ by_backend_tier = {
+ (r["web_backend"], r.get("web_tier")): r["name"] for r in rows
+ }
+ assert ("parallel", "free") in by_backend_tier
+ assert ("parallel", "paid") in by_backend_tier
+ assert ("exa", "free") in by_backend_tier
+ assert ("exa", "paid") in by_backend_tier
+ # Free rows must not prompt for a key; paid rows must.
+ for r in rows:
+ if r.get("web_tier") == "free":
+ assert r["env_vars"] == []
+ if r.get("web_tier") == "paid":
+ assert r["env_vars"], r
+
+ def test_selection_persists_tier(self):
+ from hermes_cli.tools_config import _write_provider_config
+
+ config: dict = {}
+ _write_provider_config(
+ {"web_backend": "exa", "web_tier": "free", "env_vars": []},
+ config,
+ managed_feature=None,
+ )
+ assert config["web"]["backend"] == "exa"
+ assert config["web"]["provider_tier"]["exa"] == "free"
+ # Re-selecting a tier-agnostic row clears the stale tier.
+ _write_provider_config(
+ {"web_backend": "exa", "env_vars": []}, config, managed_feature=None
+ )
+ assert "exa" not in config["web"]["provider_tier"]
+
+ def test_tier_match_highlights_correct_row(self):
+ from hermes_cli.tools_config import _web_tier_matches
+
+ free_row = {"web_backend": "parallel", "web_tier": "free"}
+ paid_row = {"web_backend": "parallel", "web_tier": "paid"}
+ cfg_free = {"web": {"backend": "parallel", "provider_tier": {"parallel": "free"}}}
+ cfg_paid = {"web": {"backend": "parallel", "provider_tier": {"parallel": "paid"}}}
+ assert _web_tier_matches(free_row, cfg_free) is True
+ assert _web_tier_matches(paid_row, cfg_free) is False
+ assert _web_tier_matches(paid_row, cfg_paid) is True
+ assert _web_tier_matches(free_row, cfg_paid) is False
+ # Auto (unset tier, no key in the hermetic env): free row highlights.
+ cfg_auto = {"web": {"backend": "parallel"}}
+ assert _web_tier_matches(free_row, cfg_auto) is True
+ assert _web_tier_matches(paid_row, cfg_auto) is False
diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py
index 237037a22f..03ea038a5d 100644
--- a/tests/tools/test_web_tools_config.py
+++ b/tests/tools/test_web_tools_config.py
@@ -216,10 +216,16 @@ class TestBackendSelection:
assert _get_backend() == "firecrawl"
def test_fallback_no_keys_defaults_to_firecrawl(self):
- """No keys, no config → 'firecrawl' (will fail at client init)."""
+ """No keys, no config, keyless tier off → 'firecrawl' sentinel.
+
+ With the keyless tier on (default), zero credentials resolves to
+ the Parallel/Exa free tier instead — covered in
+ test_web_keyless_fallback.py.
+ """
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
- patch("tools.web_tools._ddgs_package_importable", return_value=False):
+ patch("tools.web_tools._ddgs_package_importable", return_value=False), \
+ patch("agent.web_search_registry._keyless_tier_enabled", return_value=False):
assert _get_backend() == "firecrawl"
def test_invalid_config_falls_through_to_fallback(self):
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index ee815a250f..4092e31e42 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -2202,6 +2202,13 @@ web:
# and no API keys present, web tools fall back to Parallel's / Exa's
# public anonymous endpoints (rate-limited). Set false to disable.
keyless_fallback: true
+
+ # Pin Exa/Parallel to a tier (set by the hermes tools Free/Paid rows).
+ # free = always the anonymous endpoint; paid = always the keyed SDK path;
+ # unset = auto (key present -> paid, otherwise free).
+ provider_tier:
+ parallel: free
+ exa: paid
```
| Backend | Env Var | Search | Extract |
diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md
index 286895a198..863c8d672c 100644
--- a/website/docs/user-guide/features/web-search.md
+++ b/website/docs/user-guide/features/web-search.md
@@ -35,6 +35,8 @@ Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecr
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`.
:::
+**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).
+
:::tip Nous Subscribers
If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, web search and extract are available through the **[Tool Gateway](tool-gateway.md)** via managed Firecrawl — no API key needed. New installs can run `hermes setup --portal` to log in and turn on all gateway tools at once; existing installs can flip just web via `hermes tools`.
:::
From 08b7fad3a54642c5891d4f85d25fc4278af85707 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:38:08 -0700
Subject: [PATCH 4/6] test: registry zero-credential resolution may return a
keyless-capable provider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
test_no_config_no_credentials_returns_none pinned 'resolved provider
must be is_available()' — stale now that the keyless tier resolves
Parallel/Exa with is_available()=False + is_keyless_available()=True.
Accept keyed OR keyless-capable results (env-leak detection intact).
---
.../web/test_web_search_provider_plugins.py | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py
index 2314ca8a6d..a24b303a5e 100644
--- a/tests/plugins/web/test_web_search_provider_plugins.py
+++ b/tests/plugins/web/test_web_search_provider_plugins.py
@@ -278,20 +278,21 @@ class TestRegistryResolution:
def test_no_config_no_credentials_returns_none(
self,
) -> None:
- """No backend configured AND no available providers → typically None.
+ """No backend configured AND no credentials → keyless tier or ddgs.
- ``ddgs`` is the no-credential fallback; if its ``ddgs`` Python
- package is installed in the test env, ddgs will be picked.
- Otherwise the resolver returns None. Either outcome is correct.
+ Resolution order with zero credentials: ddgs if its Python package
+ is importable, else the keyless free tier (Parallel/Exa public
+ endpoints — resolves with ``is_available() == False`` but
+ ``is_keyless_available() == True``), else None (keyless tier
+ disabled). All three outcomes are correct; a provider that is
+ neither keyed nor keyless-capable means an env var leaked in.
"""
_ensure_plugins_loaded()
from agent.web_search_registry import _resolve
result = _resolve(None, capability="search")
if result is not None:
- # The only no-credential provider is ddgs; anything else
- # means an env var leaked in.
- assert result.is_available() is True
+ assert result.is_available() or result.is_keyless_available()
# ---------------------------------------------------------------------------
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 5/6] 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"`.
From a094d45095f6a431bb9e5ce8267b6bb2985bb1ed Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:32:20 -0700
Subject: [PATCH 6/6] =?UTF-8?q?ci:=20retrigger=20=E2=80=94=20workflows=20n?=
=?UTF-8?q?ever=20dispatched=20for=204d87290d39?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit