The refusal named a cause that cannot happen. `_vendor_gateway` never contacts the network, so "unreachable" was wrong for the no-identity, failed-lookup and not-entitled cases alike — and a guest was told it had no Nous identity when it holds a perfectly good one. Root of the guest case: `_error_info` dropped `account_tier`, so a guest whose account lookup failed — precisely the state where the tier can no longer be read from the JWT, because the token is inside its refresh window — came back looking like a registered identity. Carry the stored tier through the error snapshot, and both this refusal and every other tier-aware surface see it. Pick the reason in priority order (guest / no usable identity / unreachable) in one helper beside `fast_search_entitled`, so the predicate and its explanation cannot drift, and phrase it as a clause for `selection_error`'s "but …" template — the canonical `FREE_TIER_NEEDS_ACCOUNT` strings are standalone sentences and read wrong embedded mid-sentence. Also record why the `error` clause in `fast_search_entitled` is load-bearing rather than defensive, so it is not removed as belt-and-braces. The test helper now mirrors what anon_auth actually persists (the tier alongside the token), which is what makes the failed-lookup case reproducible at all.
157 lines
7.2 KiB
Python
157 lines
7.2 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. 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:
|
|
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")]
|