feat(tools): surface the free tool pool in entitlement + setup (#36153)

Read the Portal's tool_access claim (JWT + /api/oauth/account) into NousToolAccessInfo and gate managed Tool Gateway access on it: tool_gateway_entitled (paid OR live pool) and per-category tool_gateway_entitled_for(). The pool funds web/image/tts/browser but not video, so per-backend availability, the charge picker (ensure_nous_portal_access coverage_category), and managed defaults all respect coverage.

Setup: rebuild prompt_enable_tool_gateway as a per-tool checklist that renders whenever the pool is enabled, lists only pool-covered tools (video excluded for free-pool users), and is framed as the free tool pool for $0 subscribers rather than a paid subscription. get_gateway_eligible_tools now gates and filters off the entitlement snapshot.
This commit is contained in:
Siddharth Balyan
2026-06-01 06:32:48 +05:30
committed by GitHub
parent fa4ebaa8b5
commit e1c7a9aa7b
6 changed files with 449 additions and 123 deletions

View File

@ -7,13 +7,27 @@ import json
import threading
import time
import urllib.request
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal, Optional
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
# Free tool-pool coverage categories. Kept byte-for-byte aligned with the
# Portal's TOOL_COVERAGE_CATEGORIES (nous-account-service
# src/server/tool-pool-eligibility.ts). The Portal mints these into the
# `tool_access.coverage` map on the JWT and /api/oauth/account; FAL video gen
# (`fal-video`) is intentionally excluded from the pool.
TOOL_COVERAGE_CATEGORIES = (
"firecrawl",
"fal",
"fal-video",
"openai-audio",
"browser-use",
"modal",
)
_ACCOUNT_INFO_CACHE_TTL = 60
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
@ -45,6 +59,19 @@ class NousPaidServiceAccessInfo:
total_usable_credits: Optional[float] = None
@dataclass(frozen=True)
class NousToolAccessInfo:
"""Free tool-pool entitlement, decoupled from paid/billing access.
Mirrors the Portal's ``tool_access`` claim/field: ``enabled`` is true when a
positive tool-pool balance is live and not gated off; ``coverage`` maps each
tool category to whether the pool funds it (FAL video is excluded).
"""
enabled: bool = False
coverage: dict[str, bool] = field(default_factory=dict)
@dataclass(frozen=True)
class NousPortalAccountInfo:
logged_in: bool
@ -65,6 +92,7 @@ class NousPortalAccountInfo:
subscription: Optional[NousPortalSubscriptionInfo] = None
paid_service_access: Optional[bool] = None
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
tool_access: Optional[NousToolAccessInfo] = None
raw_claims: Optional[dict[str, Any]] = None
raw_account: Optional[dict[str, Any]] = None
error: Optional[str] = None
@ -79,7 +107,21 @@ class NousPortalAccountInfo:
@property
def tool_gateway_entitled(self) -> bool:
return self.paid_service_access is True
"""Coarse "entitled to any managed tool" gate: paid access OR a live
free tool pool. Use :meth:`tool_gateway_entitled_for` to gate a specific
tool category (the pool does not cover every category)."""
if self.paid_service_access is True:
return True
return self.tool_access is not None and self.tool_access.enabled
def tool_gateway_entitled_for(self, category: str) -> bool:
"""Whether a specific tool category is entitled. Paid users are entitled
everywhere; free tool-pool users only where ``coverage[category]`` is
true (e.g. image but not video)."""
if self.paid_service_access is True:
return True
ta = self.tool_access
return bool(ta and ta.enabled and ta.coverage.get(category) is True)
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
@ -102,19 +144,38 @@ def format_nous_portal_entitlement_message(
*,
capability: str = "this feature",
include_refresh_hint: bool = True,
coverage_category: Optional[str] = None,
) -> Optional[str]:
"""Return user-facing guidance for a missing Nous paid entitlement.
"""Return user-facing guidance for a missing Nous tool-gateway entitlement.
``None`` means the account is known to have paid service access. The
message intentionally works from normalized entitlement fields rather than
subscription price alone: purchased credits without a subscription still
count as paid access, while a paid subscription with exhausted usable
credits does not.
``None`` means the account is entitled to use the capability — via paid
service access OR a live free tool pool that covers it. The message works
from normalized entitlement fields rather than subscription price alone:
purchased credits without a subscription still count as paid access, while a
paid subscription with exhausted usable credits does not.
``coverage_category`` scopes the check to a single tool category (e.g.
``"fal-video"``). When given, a user who is entitled overall but whose
access does not fund that category gets a neutral billing nudge instead of a
message implying their credits are exhausted. The pool-vs-paid distinction is
never surfaced to the user.
"""
billing_url = nous_portal_billing_url(account_info)
if account_info is not None and account_info.paid_service_access is True:
return None
if account_info is not None:
if coverage_category is not None:
if account_info.tool_gateway_entitled_for(coverage_category):
return None
if account_info.tool_gateway_entitled:
# Entitled overall (e.g. via the managed tool pool), but this
# specific capability isn't covered. Surface a neutral billing
# nudge without exposing pool-vs-paid internals to the user.
return (
f"{capability} isn't included with your current Nous Portal "
f"access. Add credits or a subscription to enable it at {billing_url}."
)
elif account_info.tool_gateway_entitled:
return None
if account_info is None:
return (
@ -534,6 +595,7 @@ def _info_from_valid_jwt(
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
paid_service_access=paid_access,
paid_service_access_info=access_info,
tool_access=_tool_access_from_value(claims.get("tool_access")),
raw_claims=dict(claims),
)
@ -571,10 +633,28 @@ def _info_from_account_payload(
subscription=subscription,
paid_service_access=paid_access,
paid_service_access_info=access,
tool_access=_tool_access_from_value(payload.get("tool_access")),
raw_account=dict(payload),
)
def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]:
"""Parse a Portal ``tool_access`` object (from the JWT claim or the account
API) into :class:`NousToolAccessInfo`. Fails closed: a non-object value
yields ``None``, and only literal ``true`` counts for ``enabled`` and each
coverage entry."""
if not isinstance(value, dict):
return None
enabled = _coerce_bool(value.get("enabled")) is True
raw_coverage = value.get("coverage")
coverage: dict[str, bool] = {}
if isinstance(raw_coverage, dict):
for key, val in raw_coverage.items():
if isinstance(key, str):
coverage[key] = val is True
return NousToolAccessInfo(enabled=enabled, coverage=coverage)
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
if not isinstance(value, dict):
return None

View File

@ -29,6 +29,20 @@ _DEFAULT_PLATFORM_TOOLSETS = {
"cli": "hermes-cli",
}
# Maps a tools_config provider's ``managed_nous_feature`` to the tool-pool
# coverage category (hermes_cli.nous_account.TOOL_COVERAGE_CATEGORIES). Lets the
# `hermes tools` picker scope its entitlement gate to the selected backend, so a
# free-tool-pool user is allowed image gen but denied video gen at select time —
# consistent with the per-category feature gates in get_nous_subscription_features.
MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {
"web": "firecrawl",
"image_gen": "fal",
"video_gen": "fal-video",
"tts": "openai-audio",
"browser": "browser-use",
"modal": "modal",
}
def _uses_gateway(section: object) -> bool:
"""Return True when a config section explicitly opts into the gateway."""
@ -253,12 +267,18 @@ def get_nous_subscription_features(
except Exception:
account_info = None
# Coarse "entitled to any managed tool" gate: paid access OR a live free
# tool pool. Per-backend availability is then narrowed by coverage below
# (the pool funds image but not video, etc.).
managed_tools_flag = bool(
account_info
and account_info.logged_in
and account_info.paid_service_access is True
and account_info.tool_gateway_entitled
)
nous_auth_present = bool(account_info and account_info.logged_in)
def _entitled_for(category: str) -> bool:
return bool(account_info and account_info.tool_gateway_entitled_for(category))
subscribed = provider_is_nous or nous_auth_present
web_tool_enabled = _toolset_enabled(config, "web")
@ -332,13 +352,45 @@ def get_nous_subscription_features(
direct_browser_use = False
direct_browserbase = False
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
# Video gen uses the same fal-queue gateway as image gen.
managed_video_available = managed_image_available
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
managed_web_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("firecrawl")
and _entitled_for("firecrawl")
)
managed_image_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("fal-queue")
and _entitled_for("fal")
)
# Video gen rides the same fal-queue gateway as image gen, but the free tool
# pool funds image and NOT video — so gate it on its own coverage category
# rather than aliasing it to image. (Paid users are entitled to both.)
managed_video_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("fal-queue")
and _entitled_for("fal-video")
)
managed_tts_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("openai-audio")
and _entitled_for("openai-audio")
)
managed_browser_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("browser-use")
and _entitled_for("browser-use")
)
managed_modal_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("modal")
and _entitled_for("modal")
)
modal_state = resolve_modal_backend_state(
modal_mode,
has_direct=direct_modal,
@ -543,7 +595,7 @@ def apply_nous_managed_defaults(
if not (
features.account_info
and features.account_info.logged_in
and features.account_info.paid_service_access is True
and features.account_info.tool_gateway_entitled
):
return set()
if not features.provider_is_nous:
@ -598,7 +650,13 @@ def apply_nous_managed_defaults(
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in selected_toolsets and not fal_key_is_configured():
# Video gen is not funded by the free tool pool, so only wire managed video
# defaults for users entitled to it (paid). Pool-only users keep video off.
if (
"video_gen" in selected_toolsets
and not fal_key_is_configured()
and features.account_info.tool_gateway_entitled_for("fal-video")
):
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
@ -672,11 +730,14 @@ def get_gateway_eligible_tools(
All lists are empty when the user is not a paid Nous subscriber or
is not using Nous as their provider.
"""
if force_fresh:
managed_enabled = managed_nous_tools_enabled(force_fresh=True)
else:
managed_enabled = managed_nous_tools_enabled()
if not managed_enabled:
# Fetch entitlement once: it gates the offer (paid access OR a live free tool
# pool) AND tells us which categories are covered (the pool funds image but
# not video, etc.). Fails closed on any error.
try:
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
except Exception:
return [], [], []
if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled):
return [], [], []
if config is None:
@ -705,6 +766,13 @@ def get_gateway_eligible_tools(
has_direct: list[str] = []
already_managed: list[str] = []
for key in _ALL_GATEWAY_KEYS:
# Only offer tools the user's entitlement actually covers. For a free
# tool pool that means image but not video; paid users are covered for
# everything.
if not account_info.tool_gateway_entitled_for(
MANAGED_FEATURE_COVERAGE_CATEGORY[key]
):
continue
if opted_in.get(key):
already_managed.append(key)
elif direct.get(key):
@ -782,10 +850,14 @@ def prompt_enable_tool_gateway(
*,
force_fresh: bool = True,
) -> set[str]:
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
"""If eligible tools exist, prompt the user (per tool) to enable the Tool
Gateway.
Uses prompt_choice() with a description parameter so the curses TUI
shows the tool context alongside the choices.
"Pool enabled" is the trigger: a user with a live free tool pool (or paid
access) is shown a per-tool checklist of the covered managed backends and
picks which to route through the gateway. The free pool funds web/image/
tts/browser but not video, so the checklist only lists covered tools (the
coverage filter lives in get_gateway_eligible_tools).
Returns the set of tools that were enabled, or empty set if the user
declined or no tools were eligible.
@ -798,93 +870,60 @@ def prompt_enable_tool_gateway(
return set()
try:
from hermes_cli.setup import prompt_choice
from hermes_cli.setup import prompt_checklist
except Exception:
return set()
# Build description lines showing full status of all gateway tools
desc_parts: list[str] = [
"",
" The Tool Gateway gives you access to web search, image generation,",
" text-to-speech, and browser automation through your Nous subscription.",
" No need to sign up for separate API keys — just pick the tools you want.",
"",
# Frame the offer by entitlement: a $0 free-tool-pool user is not on a paid
# plan, so don't call it "your subscription".
try:
account_info = get_nous_portal_account_info(force_fresh=False)
except Exception:
account_info = None
pool_only = bool(
account_info
and account_info.paid_service_access is not True
and account_info.tool_access is not None
and account_info.tool_access.enabled
)
source_label = "free tool pool" if pool_only else "Nous subscription"
# Per-tool checklist: unconfigured tools first (pre-checked for new users),
# then tools where the user already has their own key (left unchecked so we
# don't override their own setup unless they ask).
offer_keys: list[str] = list(unconfigured) + list(has_direct)
labels: list[str] = [_GATEWAY_TOOL_LABELS[k] for k in unconfigured]
labels += [
f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_GATEWAY_DIRECT_LABELS[k]}"
for k in has_direct
]
if already_managed:
for k in already_managed:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using Tool Gateway")
if unconfigured:
for k in unconfigured:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — not configured")
if has_direct:
for k in has_direct:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using {_GATEWAY_DIRECT_LABELS[k]}")
# Build short choice labels — detail is in the description above
choices: list[str] = []
choice_keys: list[str] = [] # maps choice index -> action
if unconfigured and has_direct:
choices.append("Enable for all tools (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Enable only for tools without existing keys")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
elif unconfigured:
choices.append("Enable Tool Gateway")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
pre_selected = list(range(len(unconfigured)))
if pool_only:
title = "Your free Nous tool pool — pick the tools to enable:"
else:
choices.append("Enable Tool Gateway (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Skip")
choice_keys.append("skip")
description = "\n".join(desc_parts) if desc_parts else None
# Default to "Enable" when user has no direct keys (new user),
# default to "Skip" when they have existing keys to preserve.
default_idx = 0 if not has_direct else len(choices) - 1
title = (
"Your Nous subscription includes the Tool Gateway — "
"pick the tools to enable:"
)
try:
idx = prompt_choice(
"Your Nous subscription includes the Tool Gateway.",
choices,
default_idx,
description=description,
)
chosen_idx = prompt_checklist(title, labels, pre_selected)
except (KeyboardInterrupt, EOFError, OSError, SystemExit):
return set()
action = choice_keys[idx]
if action == "skip":
chosen_keys = [offer_keys[i] for i in chosen_idx if 0 <= i < len(offer_keys)]
if not chosen_keys:
return set()
if action == "all":
# Apply to switchable tools + ensure already-managed tools also
# have use_gateway persisted in config for consistency.
to_apply = list(_ALL_GATEWAY_KEYS)
else:
to_apply = unconfigured
changed = apply_gateway_defaults(config, to_apply)
changed = apply_gateway_defaults(config, chosen_keys)
if changed:
from hermes_cli.config import save_config
save_config(config)
# Only report the tools that actually switched (not already-managed ones)
newly_switched = changed - set(already_managed)
for key in sorted(newly_switched):
for key in sorted(changed):
label = _GATEWAY_TOOL_LABELS.get(key, key)
print(f"{label}: enabled via Nous subscription")
if already_managed and not newly_switched:
print(" (all tools already using Tool Gateway)")
print(f"{label}: enabled via {source_label}")
return changed
@ -893,8 +932,13 @@ def prompt_enable_tool_gateway(
# ---------------------------------------------------------------------------
def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> bool:
"""Make sure the user has paid Nous Portal access, logging in if needed.
def ensure_nous_portal_access(
*,
capability: str = "the Nous Tool Gateway",
coverage_category: Optional[str] = None,
) -> bool:
"""Make sure the user is entitled to the Nous Tool Gateway, logging in if
needed.
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
@ -908,15 +952,28 @@ def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> b
already logged in) and refreshes entitlement, so the caller can enable the
single tool the user picked.
Returns ``True`` when the account has paid service access after the flow,
``False`` otherwise (declined login, login failed, or no paid entitlement).
Entitlement is satisfied by paid service access OR a live free tool pool.
When ``coverage_category`` is given (e.g. ``"fal"`` for image gen), the pool
must cover that category specifically — so a pool user selecting video
(``"fal-video"``, not pool-funded) is correctly denied.
Returns ``True`` when the account is entitled after the flow, ``False``
otherwise (declined login, login failed, or no entitlement).
"""
def _entitled(account) -> bool:
if account is None:
return False
if coverage_category is not None:
return account.tool_gateway_entitled_for(coverage_category)
return account.tool_gateway_entitled
# Fast path: already entitled.
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
if info is not None and info.paid_service_access is True:
if _entitled(info):
return True
# If not logged in at all, run the device-code login (auth only).
@ -928,11 +985,15 @@ def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> b
except Exception:
info = None
if info is not None and info.paid_service_access is True:
if _entitled(info):
return True
# Logged in but no paid access — surface billing guidance, do not enable.
message = format_nous_portal_entitlement_message(info, capability=capability)
# Logged in but not entitled for this capability — surface neutral billing
# guidance, do not enable. coverage_category keeps a pool user who lacks this
# one category from being told their credits are exhausted.
message = format_nous_portal_entitlement_message(
info, capability=capability, coverage_category=coverage_category
)
if message:
for line in message.splitlines():
print(f" {line}")

View File

@ -1926,6 +1926,18 @@ def _visible_providers(
*activates* the gateway once paid access is confirmed.
"""
features = get_nous_subscription_features(config, force_fresh=force_fresh)
acct = features.account_info
# Pool-only users (entitled to managed tools via the free tool pool but with
# no paid access) get image gen but NOT video gen — the pool doesn't fund
# `fal-video`. Rather than advertise a managed video row that would be denied
# on select, hide it for them. Logged-out users still see it (advertising)
# and paid users are entitled to it.
pool_only = bool(
acct
and acct.logged_in
and acct.paid_service_access is not True
and acct.tool_gateway_entitled
)
visible = []
for provider in cat.get("providers", []):
# Nous-managed Tool Gateway rows stay visible regardless of auth —
@ -1938,6 +1950,14 @@ def _visible_providers(
and not features.nous_auth_present
):
continue
# Hide the managed video-gen row from pool-only users — their free tool
# pool doesn't cover video, so showing it would only lead to a denial.
if (
pool_only
and provider.get("managed_nous_feature") == "video_gen"
and not (acct and acct.tool_gateway_entitled_for("fal-video"))
):
continue
visible.append(provider)
# Inject plugin-registered image_gen backends (OpenAI today, more
@ -2711,13 +2731,17 @@ def _configure_provider(
# auth + entitlement only, no inference-provider switch and no bulk
# "enable all tools" prompt (that lives in `hermes model`).
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
from hermes_cli.nous_subscription import (
MANAGED_FEATURE_COVERAGE_CATEGORY,
ensure_nous_portal_access,
)
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
capability=f"{provider.get('name', 'the Nous Tool Gateway')}",
coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature),
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
" Not enabled — Nous Portal access is required for this backend."
)
return
@ -3075,13 +3099,17 @@ def _reconfigure_provider(
# Same inline Nous Portal login + entitlement gate as _configure_provider:
# managed Tool Gateway backends only activate with paid Portal access.
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
from hermes_cli.nous_subscription import (
MANAGED_FEATURE_COVERAGE_CATEGORY,
ensure_nous_portal_access,
)
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
capability=f"{provider.get('name', 'the Nous Tool Gateway')}",
coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature),
):
_print_warning(
" Not enabled — Nous Portal paid access is required for this backend."
" Not enabled — Nous Portal access is required for this backend."
)
return

View File

@ -309,10 +309,28 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
from hermes_cli.nous_account import NousPortalAccountInfo
# Entitled account (paid → all tools eligible) drives the offer; the prompt
# is a per-tool checklist now, so capture the call rather than scrape stdout.
monkeypatch.setattr(
"hermes_cli.nous_subscription.managed_nous_tools_enabled",
lambda *args, **kwargs: True,
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda **kwargs: NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
),
)
captured = {}
def _fake_checklist(title, items, pre_selected=None):
captured["title"] = title
captured["items"] = list(items)
return [] # decline; we only assert the prompt was offered
monkeypatch.setattr("hermes_cli.setup.prompt_checklist", _fake_checklist, raising=False)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"},
@ -338,10 +356,9 @@ def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatc
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
out = capsys.readouterr().out
# Tool Gateway prompt should be shown (input() raises OSError in pytest
# which is caught, so the prompt text appears but nothing is applied)
assert "Tool Gateway" in out
# The per-tool Tool Gateway checklist was offered.
assert "title" in captured
assert "Tool Gateway" in captured["title"] or "tool pool" in captured["title"].lower()
def test_codex_provider_uses_config_model(monkeypatch):

View File

@ -1,9 +1,19 @@
"""Tests for Nous subscription feature detection."""
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo
from hermes_cli import nous_subscription as ns
_POOL_COVERAGE = {
"firecrawl": True,
"fal": True,
"fal-video": False,
"openai-audio": True,
"browser-use": True,
"modal": True,
}
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
return NousPortalAccountInfo(
logged_in=logged_in,
@ -13,6 +23,17 @@ def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountI
)
def _pool_account() -> NousPortalAccountInfo:
"""A $0 subscriber with a live free tool pool (no paid access)."""
return NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=False,
tool_access=NousToolAccessInfo(enabled=True, coverage=_POOL_COVERAGE),
)
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
env = {"EXA_API_KEY": "exa-test"}
@ -214,7 +235,10 @@ def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_o
def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
# Paid account: entitled to every category, including video.
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
@ -233,6 +257,117 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch):
"""A free-tool-pool user is offered the covered tools but NOT video gen."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
{"model": {"provider": "nous"}}
)
assert set(unconfigured) == {"web", "image_gen", "tts", "browser"}
assert "video_gen" not in unconfigured
assert "video_gen" not in has_direct
assert "video_gen" not in already_managed
def test_get_gateway_eligible_tools_empty_when_not_entitled(monkeypatch):
"""A logged-in free user with no pool and no paid access gets nothing."""
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=False)
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
{"model": {"provider": "nous"}}
)
assert (unconfigured, has_direct, already_managed) == ([], [], [])
def _capture_checklist(monkeypatch, *, selected_idx):
"""Patch prompt_checklist to capture its args and return chosen indices."""
captured = {}
def _fake_checklist(title, items, pre_selected=None):
captured["title"] = title
captured["items"] = list(items)
captured["pre_selected"] = list(pre_selected or [])
return list(selected_idx)
import hermes_cli.setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_checklist", _fake_checklist, raising=False)
monkeypatch.setattr(
"hermes_cli.config.save_config", lambda cfg: None, raising=False
)
return captured
def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
"""Pool user's checklist lists web/image/tts/browser and never video."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
captured = _capture_checklist(monkeypatch, selected_idx=[])
config = {"model": {"provider": "nous"}}
ns.prompt_enable_tool_gateway(config)
blob = " ".join(captured["items"]).lower()
assert "firecrawl" in blob # web offered
assert "video" not in blob # video NOT offered to a pool user
# Pool-aware framing, not "subscription".
assert "free" in captured["title"].lower() and "pool" in captured["title"].lower()
def test_prompt_enable_tool_gateway_writes_only_selected(monkeypatch):
"""Selecting a subset writes use_gateway only for those tools."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
# Offered order is _ALL_GATEWAY_KEYS filtered to covered: web, image_gen, tts, browser.
# Select index 0 (web) and 1 (image_gen) only.
_capture_checklist(monkeypatch, selected_idx=[0, 1])
config = {"model": {"provider": "nous"}}
changed = ns.prompt_enable_tool_gateway(config)
assert changed == {"web", "image_gen"}
assert config["web"]["use_gateway"] is True
assert config["image_gen"]["use_gateway"] is True
assert "tts" not in config or config.get("tts", {}).get("use_gateway") is not True
assert "video_gen" not in config
def test_prompt_enable_tool_gateway_paid_user_offers_video(monkeypatch):
"""Paid users still get video gen in the offer (regression guard)."""
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
captured = _capture_checklist(monkeypatch, selected_idx=[])
ns.prompt_enable_tool_gateway({"model": {"provider": "nous"}})
blob = " ".join(captured["items"]).lower()
assert "video" in blob
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write video_gen.provider and
video_gen.use_gateway when a Nous subscriber selects video_gen

View File

@ -15,12 +15,17 @@ _VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
"""Return True when the user has paid Nous Portal service access.
"""Return True when the user is entitled to the Nous Tool Gateway.
Entitlement is paid Nous Portal service access OR a live free tool pool
(``tool_gateway_entitled``). Per-category coverage (the pool funds image but
not video, etc.) is narrowed by callers via ``tool_gateway_entitled_for``;
this coarse gate only answers "is any managed tool usable at all".
Tool Gateway availability fails closed on unknown/error entitlement. We
intentionally catch all exceptions and return False — never block startup.
``force_fresh=True`` is for interactive configuration flows that should
reflect a just-purchased subscription or credits immediately.
reflect a just-purchased subscription, credits, or pool grant immediately.
"""
try:
from hermes_cli.nous_account import get_nous_portal_account_info
@ -31,7 +36,7 @@ def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
account_info = get_nous_portal_account_info()
if not account_info.logged_in:
return False
return account_info.paid_service_access is True
return account_info.tool_gateway_entitled
except Exception:
return False