fix(web): exclude the anonymous guest tier from free fast search
The managed Perplexity ``search_type: "fast"`` route is served without funding checks, but eligibility is still identity-scoped: the anonymous guest tier has no Portal account behind it, so it must keep the keyless ring rather than claim a route the gateway may not serve it. ``resolve_free_search_gateway`` resolved on the presence of a Nous token alone, and the guest tier has a perfectly usable one, so every anonymous install would have been routed to the gateway. Add ``fast_search_entitled`` — a registered Portal identity of any tier, no credit or tool-pool requirement, anonymous tier excluded, failing closed on unknown/error — and gate the free route on it. Guests are byte-identical to before: the resolver returns None, autodetect falls through to the keyless ring, and no gateway request is made. A registered zero-credit account still gets free fast search. Extract and every other managed vendor keep the paid entitlement gate.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Free managed Perplexity fast search for any Nous identity: anonymous, or signed in with no usable
|
||||
credits and no tool pool. The gateway serves ``POST /search`` + ``search_type: "fast"`` without funding
|
||||
"""Free managed Perplexity fast search for a REGISTERED Nous Portal identity 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."""
|
||||
keeps it. The anonymous guest tier is excluded — it has no Portal account behind it, so it keeps the
|
||||
keyless ring. Real config, real auth store, real selection-to-provider dispatch over local HTTP."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
@@ -11,7 +12,9 @@ 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": {}}}
|
||||
|
||||
|
||||
@@ -88,11 +91,11 @@ 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):
|
||||
def test_zero_credit_portal_identity_autodetects_free_fast_search(monkeypatch, tmp_path, gateway_server):
|
||||
from tools import web_tools
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
state = _nous_state(EXHAUSTED, "oauth")
|
||||
_write_home(tmp_path / "home", monkeypatch, nous_state=state)
|
||||
# Premise: the paid-tool gate is closed for this identity and stays closed for extract.
|
||||
assert managed_nous_tools_enabled() is False
|
||||
@@ -105,10 +108,26 @@ def test_unentitled_nous_identity_autodetects_free_fast_search(monkeypatch, tmp_
|
||||
assert _calls(gateway_server) == [("/perplexity/search", f"Bearer {state['access_token']}", "fast")]
|
||||
|
||||
|
||||
def test_anonymous_guest_keeps_the_keyless_ring(monkeypatch, tmp_path, gateway_server):
|
||||
"""The guest tier holds a usable Nous token, so the exclusion must come from the tier itself."""
|
||||
from tools import web_tools
|
||||
from tools.managed_tool_gateway import peek_nous_access_token, resolve_free_search_gateway
|
||||
from tools.tool_backend_helpers import fast_search_entitled
|
||||
|
||||
_write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(ANON, "anonymous"))
|
||||
# Premise: a token IS present — anonymous is refused by entitlement, not by a missing credential.
|
||||
assert peek_nous_access_token()
|
||||
assert fast_search_entitled() is False
|
||||
assert resolve_free_search_gateway(token_reader=peek_nous_access_token) is None
|
||||
|
||||
assert web_tools._managed_web_search() is False
|
||||
assert web_tools._get_search_backend() != "perplexity"
|
||||
|
||||
|
||||
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")
|
||||
state = _nous_state(EXHAUSTED, "oauth")
|
||||
_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}]}}
|
||||
@@ -123,7 +142,7 @@ def test_free_fast_search_failure_skips_paid_firecrawl_but_keeps_keyless_rescue(
|
||||
@pytest.mark.parametrize("state,config", [
|
||||
(_nous_state(ANON, "anonymous"), {"nous": {"guest": False}}),
|
||||
(None, {}),
|
||||
(_nous_state(ANON, "anonymous"), {"web": {"search_backend": "exa"}}),
|
||||
(_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
|
||||
@@ -135,7 +154,7 @@ def test_free_fast_search_needs_a_usable_identity_and_no_explicit_selection(monk
|
||||
|
||||
|
||||
def test_direct_perplexity_key_beats_free_fast_search(monkeypatch, tmp_path, gateway_server):
|
||||
_write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(ANON, "anonymous"))
|
||||
_write_home(tmp_path / "home", monkeypatch, nous_state=_nous_state(EXHAUSTED, "oauth"))
|
||||
monkeypatch.setenv("PERPLEXITY_API_KEY", "direct-key")
|
||||
|
||||
assert _search()["success"] is True
|
||||
@@ -143,7 +162,7 @@ def test_direct_perplexity_key_beats_free_fast_search(monkeypatch, tmp_path, gat
|
||||
|
||||
|
||||
def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tmp_path, gateway_server):
|
||||
"""Multiplex A→B→A: profile A holds an anonymous identity, B none; each answers from its own store."""
|
||||
"""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
|
||||
@@ -151,7 +170,7 @@ def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tm
|
||||
|
||||
root = tmp_path / ".hermes"
|
||||
profile_a, profile_b = root / "profiles" / "a", root / "profiles" / "b"
|
||||
_write_home(profile_a, monkeypatch, nous_state=_nous_state(ANON, "anonymous"), config={"web": {"keyless_fallback": False}})
|
||||
_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("")
|
||||
@@ -165,4 +184,4 @@ def test_free_fast_search_eligibility_follows_the_served_profile(monkeypatch, tm
|
||||
finally:
|
||||
set_multiplex_active(False)
|
||||
assert seen[0] == seen[2] == "perplexity"
|
||||
assert seen[1] != "perplexity"
|
||||
assert seen[1] != "perplexity"
|
||||
@@ -43,8 +43,11 @@ def local_gateway(monkeypatch):
|
||||
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.
|
||||
# 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:
|
||||
|
||||
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
from tools.tool_backend_helpers import fast_search_entitled, managed_nous_tools_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -180,9 +180,13 @@ def resolve_managed_tool_gateway(
|
||||
|
||||
|
||||
def resolve_free_search_gateway(token_reader: Optional[Callable[[], Optional[str]]] = None) -> Optional[ManagedToolGatewayConfig]:
|
||||
"""Perplexity ``search_type: "fast"`` is served to every Nous identity with no funding check, so it needs a
|
||||
token this profile may use (guest-disabled and refresh rules live in the reader), not paid entitlement.
|
||||
Search only: every other vendor route goes through :func:`resolve_managed_tool_gateway`."""
|
||||
"""Perplexity ``search_type: "fast"`` is served without funding checks, so it needs a registered
|
||||
Nous identity this profile may use (guest-disabled and refresh rules live in the reader) rather
|
||||
than paid entitlement. The anonymous guest tier is excluded — it has no Portal account, so it
|
||||
keeps the keyless ring. Search only: every other vendor route goes through
|
||||
:func:`resolve_managed_tool_gateway`."""
|
||||
if not fast_search_entitled():
|
||||
return None
|
||||
return _vendor_gateway("perplexity", None, token_reader)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,22 @@ def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def fast_search_entitled(*, force_fresh: bool = False) -> bool:
|
||||
"""Eligibility for the managed Perplexity ``search_type: "fast"`` route: a registered Portal
|
||||
identity of ANY tier, with no credit or tool-pool requirement — that route is served without
|
||||
funding checks. The anonymous guest tier is excluded: it has no Portal account behind it, so
|
||||
it keeps the keyless ring. Fails closed on unknown/error, like
|
||||
:func:`managed_nous_tools_enabled`."""
|
||||
try:
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
account_info = (get_nous_portal_account_info(force_fresh=True) if force_fresh
|
||||
else get_nous_portal_account_info())
|
||||
return (bool(account_info.logged_in) and account_info.error is None
|
||||
and not account_info.is_anonymous_tier)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def nous_tool_gateway_unavailable_message(capability: str = "the Nous Tool Gateway", *,
|
||||
force_fresh: bool = False) -> str:
|
||||
"""Return account-aware guidance for an unavailable Nous Tool Gateway path."""
|
||||
|
||||
@@ -445,7 +445,7 @@ If no shared backend has **ever** been selected (no `web.backend` written by you
|
||||
| `SEARXNG_URL` | searxng |
|
||||
| `BRAVE_SEARCH_API_KEY` | brave-free |
|
||||
| `ddgs` package importable | ddgs |
|
||||
| Nous identity present (any account, including the free tier) | managed web search (`web_search` only); extract and the keyless ring below are unchanged |
|
||||
| Registered Nous Portal identity (any tier, including the no-credit free tier; anonymous guests excluded) | managed web search (`web_search` only); extract and the keyless ring below are unchanged |
|
||||
| *(nothing set at all)* | keyless ring: exa / parallel / firecrawl / keenable (round-robin) |
|
||||
|
||||
**Keyless free-tier ring:** when *no* credential above is present, requests rotate across the ring vendors' public free tiers (Exa, Parallel, Firecrawl, Keenable) so web tools work on a fresh install with zero setup — and a rate-limited request fails over to the next vendor in the ring automatically. Pin one vendor in `hermes tools` to stop the rotation (the ring is then only used as failover succession on throttles). All free tiers are vendor-rate-limited under burst load; sustained normal usage goes through fine. Set `web.keyless_fallback: false` to turn the tier off — with it off and no credentials, web tools are unavailable until a provider is configured.
|
||||
|
||||
Reference in New Issue
Block a user