Files
hermes-agent/tests/tools/test_web_free_fast_search.py
kshitijk4poor 343500b354 refactor(web): drop the gate's leftovers and keep the tier rules tested
Review and simplify pass on the guest fast-search restore.

- tools/tool_backend_helpers.py: _account_info was split out so two gates
  could share one account read. Only managed_nous_tools_enabled is left, so
  fold it back into its pre-salvage shape. Keep the branched force_fresh
  call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
  _managed_gateway only fed the removed refusal helper. search() now calls
  resolve_free_search_gateway() directly once it has the route answer. The
  module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
  removed gate. The tier carried on a failed lookup still picks the "needs
  a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
  lost their only coverage when the gate tests went, so cover them at
  their consumer, format_nous_portal_entitlement_message. The fast-search
  premise now asserts that the guest case really reads as the anonymous
  tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
  true for direct keys only. It now names the managed guest route.
2026-10-04 00:10:54 +05:30

179 lines
8.1 KiB
Python

"""Free managed Perplexity fast search for any Nous identity: anonymous guest, or signed in 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."""
import base64
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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": {}}}
def _nous_state(claims: dict, auth_method: str) -> dict:
def seg(obj):
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
token = f"{seg({'alg': 'none'})}.{seg({**claims, 'exp': int(time.time()) + 3600})}.sig"
state = {"auth_method": auth_method, "access_token": token, "expires_at": "2099-01-01T00:00:00Z"}
# anon_auth persists the tier alongside the token.
if claims.get("account_tier"):
state["account_tier"] = claims["account_tier"]
return state
@pytest.fixture
def gateway_server(monkeypatch):
class Log(list):
base = ""
requests = Log()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
requests.append((self.path, dict(self.headers), body))
ok = self.path in ("/perplexity/search", "/direct/search")
content = json.dumps({"results": [{"title": "t", "url": "https://example.test", "snippet": "s"}]}
if ok else {"error": "payment required"}).encode()
self.send_response(200 if ok else 402)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
requests.base = f"http://127.0.0.1:{server.server_port}"
monkeypatch.setenv("PERPLEXITY_GATEWAY_URL", requests.base + "/perplexity")
monkeypatch.setenv("PERPLEXITY_BASE_URL", requests.base + "/direct")
monkeypatch.setenv("FIRECRAWL_GATEWAY_URL", requests.base)
monkeypatch.setattr("tools.web_tools._firecrawl_client", None, raising=False)
try:
yield requests
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def _write_home(home, monkeypatch, *, nous_state=None, config=None):
from hermes_cli.config import atomic_config_write
from hermes_cli.nous_account import reset_nous_portal_account_info_cache
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
atomic_config_write(home / "config.yaml", {"web": {"keyless_rescue": False}, **(config or {})})
if nous_state is not None:
(home / "auth.json").write_text(json.dumps({"version": 1, "providers": {"nous": nous_state}}))
reset_nous_portal_account_info_cache()
def _search():
from tools import web_tools
from tests.tools.conftest import register_all_web_providers
register_all_web_providers()
return json.loads(web_tools.web_search_tool("free fast search", limit=3))
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):
"""Guests are the users this route exists for: an anonymous identity and a zero-credit account
both reach the gateway, while the paid gate stays closed for extract and every other vendor."""
from hermes_cli.nous_account import get_nous_portal_account_info
from tools import web_tools
from tools.tool_backend_helpers import managed_nous_tools_enabled
_write_home(tmp_path / "home", monkeypatch, nous_state=state)
# Premise: the guest case really reads as the anonymous tier, and the paid-tool gate is closed
# for this identity and stays closed for extract.
assert get_nous_portal_account_info().is_anonymous_tier is (state["auth_method"] == "anonymous")
assert managed_nous_tools_enabled() is False
assert web_tools.check_firecrawl_api_key() is False
assert web_tools._get_extract_backend() != "perplexity"
result = _search()
assert result["success"] is True, result
assert _calls(gateway_server) == [("/perplexity/search", f"Bearer {state['access_token']}", "fast")]
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")
_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}]}}
monkeypatch.setattr(keyless_mcp, "search_with_failover", lambda *a, **kw: dict(rescued, data=dict(rescued["data"])))
result = _search()
assert result["data"]["rescued_from"] == "perplexity"
assert _calls(gateway_server) == [("/down/search", f"Bearer {state['access_token']}", "fast")]
@pytest.mark.parametrize("state,config", [
(_nous_state(ANON, "anonymous"), {"nous": {"guest": False}}),
(None, {}),
(_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
_write_home(tmp_path / "home", monkeypatch, nous_state=state, config=config)
assert web_tools._managed_web_search() is False
assert web_tools._get_search_backend() != "perplexity"
def test_direct_perplexity_key_beats_free_fast_search(monkeypatch, tmp_path, gateway_server):
_write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(EXHAUSTED, "oauth"))
monkeypatch.setenv("PERPLEXITY_API_KEY", "direct-key")
assert _search()["success"] is True
assert _calls(gateway_server) == [("/direct/search", "Bearer direct-key", None)]
def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tmp_path, gateway_server):
"""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
from tools import web_tools
root = tmp_path / ".hermes"
profile_a, profile_b = root / "profiles" / "a", root / "profiles" / "b"
_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("")
set_multiplex_active(True)
try:
seen = []
for home in (profile_a, profile_b, profile_a):
reset_nous_portal_account_info_cache()
with _profile_runtime_scope(home):
seen.append(web_tools._get_search_backend())
finally:
set_multiplex_active(False)
assert seen[0] == seen[2] == "perplexity"
assert seen[1] != "perplexity"