From e1c7a9aa7b91b0c153022a7c8b55f1b469cf5c3b Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:32:48 +0530 Subject: [PATCH] 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. --- hermes_cli/nous_account.py | 100 +++++++- hermes_cli/nous_subscription.py | 253 +++++++++++++-------- hermes_cli/tools_config.py | 40 +++- tests/cli/test_cli_provider_resolution.py | 29 ++- tests/hermes_cli/test_nous_subscription.py | 139 ++++++++++- tools/tool_backend_helpers.py | 11 +- 6 files changed, 449 insertions(+), 123 deletions(-) diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index 36c7abcd7..20b851c4f 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -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 diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index abc79bbf7..65cc1ca57 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -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}") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b447c880c..326d3bb48 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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 diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index a25d903f6..07d16366d 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -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): diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index e25fd86f1..752f6fabc 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -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 diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index b1e0f834c..95e753048 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -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