diff --git a/tests/tools/test_web_free_fast_search.py b/tests/tools/test_web_free_fast_search.py index fbcecddac7..a0626f3c39 100644 --- a/tests/tools/test_web_free_fast_search.py +++ b/tests/tools/test_web_free_fast_search.py @@ -1,7 +1,8 @@ -"""Free managed Perplexity fast search for any Nous identity: anonymous, or signed in with no usable -credits and no tool pool. The gateway serves ``POST /search`` + ``search_type: "fast"`` without funding +"""Free managed Perplexity fast search for a REGISTERED Nous Portal identity with no usable credits +and no tool pool. The gateway serves ``POST /search`` + ``search_type: "fast"`` without funding checks, so the local paid-tool gate must not block this one route while every other managed vendor -keeps it. Real config, real auth store, real selection-to-provider dispatch over local HTTP.""" +keeps it. The anonymous guest tier is excluded — it has no Portal account behind it, so it keeps the +keyless ring. Real config, real auth store, real selection-to-provider dispatch over local HTTP.""" import base64 import json @@ -11,7 +12,9 @@ from threading import Thread import pytest +# Anonymous guest: NAS tier claim says "anonymous" — no Portal account behind the identity. ANON = {"sub": "anon-1", "account_tier": "anonymous", "paid_access": False} +# Registered Portal account, signed in, no credits and no tool pool. EXHAUSTED = {"sub": "user-1", "paid_access": False, "tool_access": {"enabled": False, "coverage": {}}} @@ -88,11 +91,11 @@ def _calls(log): return [(path, headers["Authorization"], body.get("search_type")) for path, headers, body in log] -@pytest.mark.parametrize("state", [_nous_state(ANON, "anonymous"), _nous_state(EXHAUSTED, "oauth")]) -def test_unentitled_nous_identity_autodetects_free_fast_search(monkeypatch, tmp_path, gateway_server, state): +def test_zero_credit_portal_identity_autodetects_free_fast_search(monkeypatch, tmp_path, gateway_server): from tools import web_tools from tools.tool_backend_helpers import managed_nous_tools_enabled + state = _nous_state(EXHAUSTED, "oauth") _write_home(tmp_path / "home", monkeypatch, nous_state=state) # Premise: the paid-tool gate is closed for this identity and stays closed for extract. assert managed_nous_tools_enabled() is False @@ -105,10 +108,26 @@ def test_unentitled_nous_identity_autodetects_free_fast_search(monkeypatch, tmp_ assert _calls(gateway_server) == [("/perplexity/search", f"Bearer {state['access_token']}", "fast")] +def test_anonymous_guest_keeps_the_keyless_ring(monkeypatch, tmp_path, gateway_server): + """The guest tier holds a usable Nous token, so the exclusion must come from the tier itself.""" + from tools import web_tools + from tools.managed_tool_gateway import peek_nous_access_token, resolve_free_search_gateway + from tools.tool_backend_helpers import fast_search_entitled + + _write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(ANON, "anonymous")) + # Premise: a token IS present — anonymous is refused by entitlement, not by a missing credential. + assert peek_nous_access_token() + assert fast_search_entitled() is False + assert resolve_free_search_gateway(token_reader=peek_nous_access_token) is None + + assert web_tools._managed_web_search() is False + assert web_tools._get_search_backend() != "perplexity" + + def test_free_fast_search_failure_skips_paid_firecrawl_but_keeps_keyless_rescue(monkeypatch, tmp_path, gateway_server): from plugins.web import keyless_mcp - state = _nous_state(ANON, "anonymous") + state = _nous_state(EXHAUSTED, "oauth") _write_home(tmp_path / "home", monkeypatch, nous_state=state, config={"web": {"keyless_rescue": True}}) monkeypatch.setenv("PERPLEXITY_GATEWAY_URL", gateway_server.base + "/down") rescued = {"success": True, "data": {"web": [{"title": "ring", "url": "https://ring.test", "description": "", "position": 1}]}} @@ -123,7 +142,7 @@ def test_free_fast_search_failure_skips_paid_firecrawl_but_keeps_keyless_rescue( @pytest.mark.parametrize("state,config", [ (_nous_state(ANON, "anonymous"), {"nous": {"guest": False}}), (None, {}), - (_nous_state(ANON, "anonymous"), {"web": {"search_backend": "exa"}}), + (_nous_state(EXHAUSTED, "oauth"), {"web": {"search_backend": "exa"}}), ]) def test_free_fast_search_needs_a_usable_identity_and_no_explicit_selection(monkeypatch, tmp_path, gateway_server, state, config): from tools import web_tools @@ -135,7 +154,7 @@ def test_free_fast_search_needs_a_usable_identity_and_no_explicit_selection(monk def test_direct_perplexity_key_beats_free_fast_search(monkeypatch, tmp_path, gateway_server): - _write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(ANON, "anonymous")) + _write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(EXHAUSTED, "oauth")) monkeypatch.setenv("PERPLEXITY_API_KEY", "direct-key") assert _search()["success"] is True @@ -143,7 +162,7 @@ def test_direct_perplexity_key_beats_free_fast_search(monkeypatch, tmp_path, gat def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tmp_path, gateway_server): - """Multiplex A→B→A: profile A holds an anonymous identity, B none; each answers from its own store.""" + """Multiplex A→B→A: profile A holds a zero-credit Portal identity, B none; each answers from its own store.""" from hermes_cli.nous_account import reset_nous_portal_account_info_cache from agent.secret_scope import set_multiplex_active from gateway.run import _profile_runtime_scope @@ -151,7 +170,7 @@ def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tm root = tmp_path / ".hermes" profile_a, profile_b = root / "profiles" / "a", root / "profiles" / "b" - _write_home(profile_a, monkeypatch, nous_state=_nous_state(ANON, "anonymous"), config={"web": {"keyless_fallback": False}}) + _write_home(profile_a, monkeypatch, nous_state=_nous_state(EXHAUSTED, "oauth"), config={"web": {"keyless_fallback": False}}) _write_home(profile_b, monkeypatch, config={"web": {"keyless_fallback": False}}) monkeypatch.setenv("HERMES_HOME", str(root)) (root / "config.yaml").write_text("") @@ -165,4 +184,4 @@ def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tm finally: set_multiplex_active(False) assert seen[0] == seen[2] == "perplexity" - assert seen[1] != "perplexity" + assert seen[1] != "perplexity" \ No newline at end of file diff --git a/tests/tools/test_web_managed_routing.py b/tests/tools/test_web_managed_routing.py index a6f833aa13..be8d3cd658 100644 --- a/tests/tools/test_web_managed_routing.py +++ b/tests/tools/test_web_managed_routing.py @@ -43,8 +43,11 @@ def local_gateway(monkeypatch): monkeypatch.setenv("PERPLEXITY_GATEWAY_URL", base + "/perplexity") monkeypatch.setenv("FIRECRAWL_GATEWAY_URL", base) monkeypatch.setenv("PERPLEXITY_BASE_URL", base + "/direct") - # Only entitlement is stubbed: route and credential resolution remain real. + # Only entitlement is stubbed: route and credential resolution remain real. Fast search carries + # its own (registered-identity) gate, stubbed here to match the entitled premise; the real tier + # resolution — including the anonymous exclusion — lives in test_web_free_fast_search.py. monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: True) + monkeypatch.setattr(managed_tool_gateway, "fast_search_entitled", lambda **kw: True) # CI omits the firecrawl extra; stand in for the SDK, still over real HTTP. class FirecrawlSDK: diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 2968165709..4ff7669172 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from typing import Callable, Optional from hermes_constants import get_hermes_home -from tools.tool_backend_helpers import managed_nous_tools_enabled +from tools.tool_backend_helpers import fast_search_entitled, managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -180,9 +180,13 @@ def resolve_managed_tool_gateway( def resolve_free_search_gateway(token_reader: Optional[Callable[[], Optional[str]]] = None) -> Optional[ManagedToolGatewayConfig]: - """Perplexity ``search_type: "fast"`` is served to every Nous identity with no funding check, so it needs a - token this profile may use (guest-disabled and refresh rules live in the reader), not paid entitlement. - Search only: every other vendor route goes through :func:`resolve_managed_tool_gateway`.""" + """Perplexity ``search_type: "fast"`` is served without funding checks, so it needs a registered + Nous identity this profile may use (guest-disabled and refresh rules live in the reader) rather + than paid entitlement. The anonymous guest tier is excluded — it has no Portal account, so it + keeps the keyless ring. Search only: every other vendor route goes through + :func:`resolve_managed_tool_gateway`.""" + if not fast_search_entitled(): + return None return _vendor_gateway("perplexity", None, token_reader) diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index 01f75ef9ab..30fb9ad2be 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -28,6 +28,22 @@ def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool: return False +def fast_search_entitled(*, force_fresh: bool = False) -> bool: + """Eligibility for the managed Perplexity ``search_type: "fast"`` route: a registered Portal + identity of ANY tier, with no credit or tool-pool requirement — that route is served without + funding checks. The anonymous guest tier is excluded: it has no Portal account behind it, so + it keeps the keyless ring. Fails closed on unknown/error, like + :func:`managed_nous_tools_enabled`.""" + try: + from hermes_cli.nous_account import get_nous_portal_account_info + account_info = (get_nous_portal_account_info(force_fresh=True) if force_fresh + else get_nous_portal_account_info()) + return (bool(account_info.logged_in) and account_info.error is None + and not account_info.is_anonymous_tier) + except Exception: + return False + + def nous_tool_gateway_unavailable_message(capability: str = "the Nous Tool Gateway", *, force_fresh: bool = False) -> str: """Return account-aware guidance for an unavailable Nous Tool Gateway path.""" diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index d046c4e1e7..e1c5b2b63a 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -445,7 +445,7 @@ If no shared backend has **ever** been selected (no `web.backend` written by you | `SEARXNG_URL` | searxng | | `BRAVE_SEARCH_API_KEY` | brave-free | | `ddgs` package importable | ddgs | -| Nous identity present (any account, including the free tier) | managed web search (`web_search` only); extract and the keyless ring below are unchanged | +| Registered Nous Portal identity (any tier, including the no-credit free tier; anonymous guests excluded) | managed web search (`web_search` only); extract and the keyless ring below are unchanged | | *(nothing set at all)* | keyless ring: exa / parallel / firecrawl / keenable (round-robin) | **Keyless free-tier ring:** when *no* credential above is present, requests rotate across the ring vendors' public free tiers (Exa, Parallel, Firecrawl, Keenable) so web tools work on a fresh install with zero setup — and a rate-limited request fails over to the next vendor in the ring automatically. Pin one vendor in `hermes tools` to stop the rotation (the ring is then only used as failover succession on throttles). All free tiers are vendor-rate-limited under burst load; sustained normal usage goes through fine. Set `web.keyless_fallback: false` to turn the tier off — with it off and no credentials, web tools are unavailable until a provider is configured.