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).
This commit is contained in:
Teknium
2026-08-19 15:15:18 -07:00
parent 7b25941b0e
commit 96c2fd3c04
12 changed files with 899 additions and 14 deletions
+33 -1
View File
@@ -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,