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.
179 lines
8.1 KiB
Python
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" |