Files
hermes-agent/tests/tools/test_web_managed_routing.py
kshitijk4poor 96ac3d349f fix(web): restore free fast search for anonymous guest users
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.

This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.

Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
2026-10-04 00:10:54 +05:30

154 lines
6.9 KiB
Python

"""Real config, dispatch and local HTTP must preserve web billing boundaries."""
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import pytest
import httpx
@pytest.fixture
def local_gateway(monkeypatch):
from tools import managed_tool_gateway
requests = []
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))
if self.path == "/v2/search":
status = 200
payload = {"success": True, "data": {"web": [{
"title": "Local fixture", "url": "https://example.test", "description": "fixture",
}]}}
else:
status, payload = 503, {"error": "local test outage"}
content = json.dumps(payload).encode()
self.send_response(status)
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()
base = f"http://127.0.0.1:{server.server_port}"
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "test-nous-token")
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.
monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: True)
# CI omits the firecrawl extra; stand in for the SDK, still over real HTTP.
class FirecrawlSDK:
def __init__(self, api_key, api_url):
self.api_key, self.api_url = api_key, api_url
def search(self, query, limit):
response = httpx.post(
f"{self.api_url}/v2/search", json={"query": query, "limit": limit},
headers={"Authorization": f"Bearer {self.api_key}"},
)
return response.json()
monkeypatch.setattr("plugins.web.firecrawl.provider.Firecrawl", FirecrawlSDK)
monkeypatch.setattr("tools.web_tools._firecrawl_client", None, raising=False)
try:
yield requests
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
@pytest.mark.parametrize(
"selection,direct_key,expected_paths",
[
({"backend": "nous"}, False, ["/perplexity/search", "/v2/search"]),
({"backend": "nous"}, True, ["/direct/search"]),
({"backend": "nous", "search_backend": "perplexity"}, True, ["/direct/search"]),
({"search_backend": "perplexity"}, False, []),
({"backend": "nous", "search_backend": "perplexity"}, False, []),
({"backend": "perplexity"}, False, []),
({"backend": "firecrawl", "search_backend": "perplexity"}, False, []),
({}, False, ["/perplexity/search", "/v2/search"]),
({"extract_backend": "exa"}, False, ["/perplexity/search", "/v2/search"]),
],
)
def test_only_managed_search_may_use_billed_fallback(
monkeypatch, tmp_path, local_gateway, selection, direct_key, expected_paths,
):
from hermes_cli.config import atomic_config_write
from tools import web_tools
from tests.tools.conftest import register_all_web_providers
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
atomic_config_write(tmp_path / "config.yaml", {
"web": {**selection, "keyless_rescue": False, "cache_enabled": True},
})
if direct_key:
monkeypatch.setenv("PERPLEXITY_API_KEY", "test-direct-key")
else:
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
register_all_web_providers()
expected = [
(path, "Bearer test-direct-key" if path == "/direct/search" else "Bearer test-nous-token")
for path in expected_paths
]
for attempt in (1, 2):
result = json.loads(web_tools.web_search_tool("local fixture", limit=3))
# A fallback must not be cached: the identical query retries Perplexity.
assert [(path, headers["Authorization"]) for path, headers, _ in local_gateway] == expected * attempt
assert result["success"] == bool(expected_paths and expected_paths[-1] == "/v2/search")
if result["success"]:
assert result["data"]["fallback_from"] == "managed_primary"
assert "local test outage" in result["data"]["backend_error"]
assert web_tools._get_extract_backend() == selection.get("extract_backend", "firecrawl")
for path, headers, body in local_gateway:
if path != "/v2/search":
managed = path == "/perplexity/search"
assert body.get("search_type") == ("fast" if managed else None)
assert body["search_context_size"] == "low"
assert headers.get("X-Pplx-Integration") == (None if managed else "hermes-agent")
def test_managed_search_without_identity_names_the_gateway(monkeypatch, tmp_path, local_gateway):
from hermes_cli.config import atomic_config_write
from tools import managed_tool_gateway, web_tools
from tests.tools.conftest import register_all_web_providers
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
atomic_config_write(tmp_path / "config.yaml", {"web": {"backend": "nous", "keyless_rescue": False}})
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: False)
register_all_web_providers()
error = json.loads(web_tools.web_search_tool("local fixture", limit=3))["error"]
assert "no usable Nous identity" in error and "hermes tools" in error
assert "PERPLEXITY_API_KEY" not in error
assert local_gateway == []
def test_unentitled_managed_search_gets_fast_search_but_no_billed_fallback(monkeypatch, tmp_path, local_gateway):
from hermes_cli.config import atomic_config_write
from tools import managed_tool_gateway, web_tools
from tests.tools.conftest import register_all_web_providers
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
atomic_config_write(tmp_path / "config.yaml", {"web": {"backend": "nous", "keyless_rescue": False}})
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: False)
register_all_web_providers()
# The fixture 503s the Perplexity route; an entitled caller would then be served by /v2/search.
assert json.loads(web_tools.web_search_tool("local fixture", limit=3))["success"] is False
assert [(path, body.get("search_type")) for path, _, body in local_gateway] == [("/perplexity/search", "fast")]