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.
154 lines
6.9 KiB
Python
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")]
|