Skip to content

feat: web search works keyless on fresh installs (Parallel + Exa free tiers) - #90313

Merged
teknium1 merged 9 commits into
mainfrom
feat/keyless-web-search-fallback
Aug 20, 2026
Merged

feat: web search works keyless on fresh installs (Parallel + Exa free tiers)#90313
teknium1 merged 9 commits into
mainfrom
feat/keyless-web-search-fallback

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

A fresh Hermes install with zero web credentials now gets working web_search and web_extract out 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-RPC tools/call client for mcp.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 shapes
  • agent/web_search_provider.py: new is_keyless_available() capability flag — a separate, weaker tier that deliberately never leaks into is_available(), so the legacy preference walk is untouched
  • plugins/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 gate
  • tools/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 installs
  • hermes_cli/config_defaults.py: web.keyless_fallback: true (set false to disable the tier entirely)
  • Docs: 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_id is a random per-process UUID (rotates every restart, never persisted), and their optional model_name analytics field is omitted.

Validation

Check Result
tests/tools/test_web_keyless_fallback.py (26 new: parsing, routing, precedence, disable flag) 26/26 pass
Sibling web provider suites (133 tests) pass (2 pre-existing lazy-install env failures, identical on main)
Live E2E, isolated HERMES_HOME, zero creds backend resolves to parallel; real search returned 3 results; real extract returned 1.1KB from nousresearch.com
Live E2E, web.backend: exa, no key keyless Exa search returned real results
keyless_fallback: false resolves to legacy sentinel, check_web_api_key() False
Keyed-vs-keyless precedence TAVILY_API_KEY alone → tavily; EXA_API_KEY alone → keyed exa beats keyless parallel

Infographic

Keyless web search fallback

…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).
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

running on 1fa66f2 — Merge remote-tracking branch 'origin/main' into feat/keyless


Still running 1 job: CI timing report

⚠️ Warnings

OSV vulnerability scan · View job

7 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.

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).
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins tool/web Web search and extraction area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 19, 2026
…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 andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_KEY present, clicking Exa · Free for Search leaves tier auto, so the paid SDK runs;
  • with a stale free tier, 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 / @alexferrari88 is 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-adami is 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 / @shaneblick is 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 / @teknium1 is 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.

@teknium1
teknium1 enabled auto-merge August 20, 2026 02:36
@teknium1
teknium1 merged commit aebab05 into main Aug 20, 2026
47 checks passed
@teknium1
teknium1 deleted the feat/keyless-web-search-fallback branch August 20, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/web Web search and extraction type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants