feat: web search works keyless on fresh installs (Parallel + Exa free tiers) - #90313
Conversation
…lel + 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).
૮ >ﻌ< ა ci reviewrunning on 1fa66f2 — Merge remote-tracking branch 'origin/main' into feat/keyless Still running 1 job:
|
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).
…aid keyed endpoint 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.<name>: - 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.
…e provider 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).
…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.
…ch-fallback # Conflicts: # hermes_cli/tools_config.py
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head 095f00337710446f6e446515d63781080326802f against base 2eb0b3b2c895e4a6f99714a52d35578088ad8ec7. Current main is de322448ac435b720177b94496e51ae6c10ad543, four Desktop-only commits beyond the merge base with no direct overlap in this web/provider diff. There were no prior human reviews or inline threads on this head. Exact-head CI, Docker, and Nix are all green.
The core architecture is good: keyless capability is kept separate from ordinary is_available(), the anonymous tier sits behind configured/keyed/managed/DDGS routes, the existing input secret/URL-safety gates remain above provider dispatch, and the Parallel correlation id is random per process with the optional analytics field omitted. I found three blockers at the egress-policy, selection-identity, and registration boundaries.
1. web.keyless_fallback: false is not an authoritative kill switch
The docs and config comment say this setting disables the keyless tier entirely, but plugins/web/keyless_mcp.py::use_keyless() checks provider_tier == "free" first and returns True without consulting keyless_enabled(). An explicit Free row therefore still sends requests to Exa/Parallel while the global keyless flag is false.
The same boundary fails open when policy evidence is unavailable: both keyless_enabled() and agent.web_search_registry._keyless_tier_enabled() return True on read errors, while a malformed config is served through defaults. For a feature that introduces anonymous third-party egress on fresh installs, “cannot establish the opt-out state” cannot be interpreted as permission to transmit.
Please make one authoritative policy decision precede every MCP path. false (including an administrator-managed false) and invalid/unavailable config state must prevent the network call; genuinely absent valid config may retain the product default. If explicit free is intentionally allowed to override keyless_fallback: false, the setting and docs need to be renamed/redefined as auto-fallback-only and a separate non-overridable egress control is still needed. Add explicit-Free + global-false and corrupt/unreadable-config regressions that assert neither vendor is called.
2. The Desktop’s real per-capability picker drops the Free/Paid identity
The whole-provider helper now persists web.provider_tier.<backend>, and the unit tests prove that helper. Desktop does not use that path for web rows with capabilities: ToolsetConfigPanel hides the whole-backend button and calls selectToolsetProvider(..., capability) from “Use for Search/Extract.” The server’s capability branch resolves the exact Free/Paid row, then writes only web.<capability>_backend = backend; it discards prov.web_tier.
Concrete outcomes:
- with
EXA_API_KEYpresent, clicking Exa · Free for Search leaves tierauto, so the paid SDK runs; - with a stale
freetier, clicking Exa · Paid for Extract can continue using the anonymous endpoint; - the UI permits Free for one capability and Paid for the other, but the proposed schema has only one provider-global
provider_tier.exa, so that state cannot be represented even after the missing write is added.
Please decide the ownership model and make the UI/API/schema agree: either tier is capability-scoped, or choosing a tier is explicitly provider-global and the UI must not offer contradictory per-capability tier choices. Exercise the actual HTTP endpoint/renderer contract with stale-opposite-tier cases; helper-only tests do not cover this surface. The existing split-provider → whole-provider transition also needs a composed witness, because search_backend/extract_backend still outrank web.backend and can make “pick one explicitly to pin it” false.
3. The zero-setup guarantee omits the registration hardening that previously made it true
This defect has production provenance. #34971 by @NormallyGaussian supplied the original keyless Parallel implementation and was merged through #43798 with authorship preserved. #44182 by @xxxigm, merged through #44433, then proved that merely triggering plugin discovery was insufficient: a failed or empty sweep stranded the web registry, so both supposedly zero-setup tools reported “No provider configured.” The merged repair verified registration after discovery, directly registered bundled providers when needed, honored plugins.disabled, and added failed-sweep retry hardening. #46350 later removed the keyless feature and its keyless-specific fallback together.
#90313 restores the remote keyless default, but _ensure_web_plugins_loaded() is again only “call discovery and log on failure.” The new tests either pre-populate the registry or stub that helper; none reproduce a raised/empty real discovery sweep. Please restore an equivalent same-call registration guarantee for the keyless candidates (respecting explicit disables), or attach proof that the current packaging/discovery architecture makes the earlier production failure impossible. Preserve @NormallyGaussian and @xxxigm in the lineage rather than presenting this as greenfield work.
Merge topology
- #89766 /
@alexferrari88is overlapping keyed-only Parallel GA/v1 repair, not a duplicate. This head still exposes the Paid row over the known beta/0.4.2 path (beta.search/beta.extract). Either restack #90313 on #89766 or land #89766 first and compose the provider/tests; conflict-only resolution is not enough. - #90188 /
@enzo-adamiis complementary local keyless extraction with redirect-by-redirect SSRF/policy enforcement and hard byte/time ceilings. It is the privacy-preserving extract side of the product choice, not duplicate search work. - #72367 /
@shaneblickis complementary provider-final-URL validation. If it lands, its invariant must cover the new keyless early-return paths as well as the keyed SDK paths. - Merged #90317 /
@teknium1is the strict provider-selection foundation. This PR makes its unresolved absence-vs-unavailable/config-authority edge materially higher impact by turning autodetect into default external egress.
Re-review gate: authoritative fail-closed keyless policy; end-to-end tier identity across Desktop capability selection and split/whole transitions; restored discovery/registration witness with predecessor credit; semantic composition with #89766 and #72367 as applicable; current-main rebase; fresh exact-head CI/Docker/Nix.
…ch-fallback # Conflicts: # website/docs/user-guide/configuration.md
Summary
A fresh Hermes install with zero web credentials now gets working
web_searchandweb_extractout of the box — backend resolution falls back to Parallel's and Exa's public anonymous free-tier endpoints (the same keyless endpoints opencode ships as its default search path) instead of dead-ending on the nonfunctional firecrawl sentinel.The keyless tier is strictly last-resort: any configured backend, any present API key, the managed tool gateway, and importable ddgs all win first. Keyed setups can never be silently downgraded onto a free tier.
Changes
plugins/web/keyless_mcp.py(new): minimal JSON-RPCtools/callclient formcp.exa.ai/mcp+search.parallel.ai/mcp— SSE and plain-JSON response parsing, typed errors (rate limits surface with "get an API key" guidance), search + extract mapping into the legacy provider response shapesagent/web_search_provider.py: newis_keyless_available()capability flag — a separate, weaker tier that deliberately never leaks intois_available(), so the legacy preference walk is untouchedplugins/web/{exa,parallel}/provider.py: route to the keyless endpoints when their key is absent; keyed SDK paths unchanged; setup schemas now badge "free tier · paid with key"agent/web_search_registry.py:_KEYLESS_PREFERENCE(parallel → exa) walked only after the availability-filtered legacy walk finds nothing;_keyless_tier_enabled()config gatetools/web_tools.py: same keyless tier at the end of_get_backend()'s cascade (before the firecrawl sentinel);check_web_api_key()now triggers plugin discovery and lights the web tools up on zero-credential installshermes_cli/config_defaults.py:web.keyless_fallback: true(setfalseto disable the tier entirely)web-search.md+configuration.md(backend table, auto-detection chain, keyless tier notes)Privacy: keyless requests carry no user identifiers — Parallel's free-tier
session_idis a random per-process UUID (rotates every restart, never persisted), and their optionalmodel_nameanalytics field is omitted.Validation
tests/tools/test_web_keyless_fallback.py(26 new: parsing, routing, precedence, disable flag)HERMES_HOME, zero credsparallel; real search returned 3 results; real extract returned 1.1KB from nousresearch.comweb.backend: exa, no keykeyless_fallback: falsecheck_web_api_key()FalseTAVILY_API_KEYalone → tavily;EXA_API_KEYalone → keyed exa beats keyless parallelInfographic