diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f19393337..abc79bbf7 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -7,7 +7,11 @@ from pathlib import Path from typing import Dict, Iterable, Optional, Set from hermes_cli.config import get_env_value, load_config -from hermes_cli.nous_account import NousPortalAccountInfo, get_nous_portal_account_info +from hermes_cli.nous_account import ( + NousPortalAccountInfo, + format_nous_portal_entitlement_message, + get_nous_portal_account_info, +) from tools.managed_tool_gateway import is_managed_tool_gateway_ready from utils import is_truthy_value from tools.tool_backend_helpers import ( @@ -882,3 +886,136 @@ def prompt_enable_tool_gateway( if already_managed and not newly_switched: print(" (all tools already using Tool Gateway)") return changed + + +# --------------------------------------------------------------------------- +# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`) +# --------------------------------------------------------------------------- + + +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. + + Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway + backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous + login, this: + + - does NOT change the inference provider (``model.provider`` is untouched), + - does NOT run model selection, and + - does NOT offer the bulk "enable for all tools" Tool Gateway prompt. + + It only performs the Nous Portal device-code OAuth (when the user isn't + 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). + """ + # 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: + return True + + # If not logged in at all, run the device-code login (auth only). + if info is None or not info.logged_in: + if not _run_nous_portal_login_only(capability=capability): + return False + 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: + return True + + # Logged in but no paid access — surface billing guidance, do not enable. + message = format_nous_portal_entitlement_message(info, capability=capability) + if message: + for line in message.splitlines(): + print(f" {line}") + return False + + +def _run_nous_portal_login_only(*, capability: str) -> bool: + """Run the Nous Portal device-code OAuth and persist credentials only. + + No model selection, no provider switch, no Tool Gateway bulk prompt. + Returns ``True`` on a successful login, ``False`` if the user declined or + the flow failed. + """ + try: + from hermes_cli.auth import ( + _auth_store_lock, + _load_auth_store, + _nous_device_code_login, + _read_shared_nous_state, + _save_auth_store, + _save_provider_state, + _sync_nous_pool_from_auth_store, + _try_import_shared_nous_state, + _write_shared_nous_state, + ) + except Exception as exc: # pragma: no cover - defensive + print(f" Could not start Nous Portal login: {exc}") + return False + + print() + print(f" {capability} requires a Nous Portal login.") + try: + proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return False + if proceed not in {"", "y", "yes"}: + print(" Skipped Nous Portal login.") + return False + + try: + # Snapshot the active_provider so a tool-config login never silently + # switches the user's inference provider to Nous. + with _auth_store_lock(): + prior_active_provider = _load_auth_store().get("active_provider") + + auth_state = None + shared = _read_shared_nous_state() + if shared: + try: + do_import = input( + " Found existing Nous OAuth credentials. Import them? [Y/n]: " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + do_import = "y" + if do_import in {"", "y", "yes"}: + auth_state = _try_import_shared_nous_state(timeout_seconds=15.0) + + if auth_state is None: + auth_state = _nous_device_code_login() + + with _auth_store_lock(): + auth_store = _load_auth_store() + _save_provider_state(auth_store, "nous", auth_state) + # Preserve the user's existing inference provider — this login is + # for tool entitlement only, not a provider switch. + if prior_active_provider: + auth_store["active_provider"] = prior_active_provider + else: + auth_store.pop("active_provider", None) + _save_auth_store(auth_store) + + _write_shared_nous_state(auth_state) + _sync_nous_pool_from_auth_store() + print(" Nous Portal login successful.") + return True + except KeyboardInterrupt: + print("\n Login cancelled.") + return False + except SystemExit: + # _nous_device_code_login raises SystemExit on subscription_required; + # it already printed billing guidance. + return False + except Exception as exc: + print(f" Nous Portal login failed: {exc}") + return False diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 4b495d4d3..8322ff0d8 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1876,18 +1876,26 @@ def _visible_providers( *, force_fresh: bool = False, ) -> list[dict]: - """Return provider entries visible for the current auth/config state.""" + """Return provider entries visible for the current auth/config state. + + Nous-managed Tool Gateway rows (``managed_nous_feature``) are always + shown — even to logged-out / unentitled users — so the picker advertises + that the capability exists. Selecting one drives an inline Nous Portal + login + entitlement check (see ``_configure_provider``); the row only + *activates* the gateway once paid access is confirmed. + """ features = get_nous_subscription_features(config, force_fresh=force_fresh) - managed_available = bool( - features.account_info - and features.account_info.logged_in - and features.account_info.paid_service_access is True - ) visible = [] for provider in cat.get("providers", []): - if provider.get("managed_nous_feature") and not managed_available: - continue - if provider.get("requires_nous_auth") and not features.nous_auth_present: + # Nous-managed Tool Gateway rows stay visible regardless of auth — + # selecting one drives an inline Portal login. A `requires_nous_auth` + # row that is NOT a managed gateway feature (pure pre-auth UX) is + # still hidden until the user is logged in. + if ( + provider.get("requires_nous_auth") + and not provider.get("managed_nous_feature") + and not features.nous_auth_present + ): continue visible.append(provider) @@ -1933,22 +1941,16 @@ def _hidden_nous_gateway_message( *, force_fresh: bool = False, ) -> str: - """Return a reason when a category's Nous provider is hidden.""" - features = get_nous_subscription_features(config, force_fresh=force_fresh) - managed_available = bool( - features.account_info - and features.account_info.logged_in - and features.account_info.paid_service_access is True - ) - if managed_available: - return "" - if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])): - return "" - message = format_nous_portal_entitlement_message( - features.account_info, - capability=capability, - ) - return message or "" + """Deprecated: Nous Tool Gateway rows are no longer hidden. + + Previously this returned a "log in / upgrade" banner shown above a + category when its Nous-managed rows were filtered out for unentitled + users. Those rows are now always listed (see ``_visible_providers``), and + the login + entitlement guidance happens inline when the user selects one + (``ensure_nous_portal_access``). Kept as a no-op so call sites stay simple; + always returns an empty string. + """ + return "" _POST_SETUP_INSTALLED: dict = { @@ -2132,14 +2134,17 @@ def _configure_tool_category( configured = "" else: configured = " [configured]" - # Highlight Nous-managed entries when the user has Portal auth. - # curses_radiolist can't render ANSI inside item strings, so we - # use a plain unicode star + parenthetical phrase. Suppressed - # when no Portal auth is present so non-subscribers see the - # picker unchanged. + # Mark Nous-managed entries. Logged-in paid subscribers get the + # "included" star; everyone else gets a "via Nous Portal" hint so + # it's clear selecting the row triggers a Portal login. The rows + # are always shown now (see _visible_providers) — selecting one + # drives an inline login + entitlement check. sub_marker = "" - if _nous_logged_in and p.get("managed_nous_feature"): - sub_marker = " ★ Included with your Nous subscription" + if p.get("managed_nous_feature"): + if _nous_logged_in: + sub_marker = " ★ Included with your Nous subscription" + else: + sub_marker = " ★ via Nous Portal (login on select)" provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") # Add skip option @@ -2558,7 +2563,26 @@ def _configure_provider( env_vars = provider.get("env_vars", []) managed_feature = provider.get("managed_nous_feature") - if provider.get("requires_nous_auth"): + # Nous-managed Tool Gateway backends are always listed (see + # _visible_providers), but only *activate* once the user has paid Nous + # Portal access. Selecting one runs an inline Portal login when needed — + # 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 + + if not ensure_nous_portal_access( + capability=f"{provider.get('name', 'the Nous Tool Gateway')}" + ): + _print_warning( + " Not enabled — Nous Portal paid access is required for this backend." + ) + return + + # Pure pre-auth UX rows (requires_nous_auth without a managed gateway + # feature) keep the old gate. Managed rows are handled by the inline + # login above, so don't double-check them here. + if provider.get("requires_nous_auth") and not managed_feature: features = get_nous_subscription_features(config, force_fresh=force_fresh) entitled = bool( features.account_info and features.account_info.paid_service_access is True @@ -2922,7 +2946,22 @@ def _reconfigure_provider( env_vars = provider.get("env_vars", []) managed_feature = provider.get("managed_nous_feature") - if provider.get("requires_nous_auth"): + # 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 + + if not ensure_nous_portal_access( + capability=f"{provider.get('name', 'the Nous Tool Gateway')}" + ): + _print_warning( + " Not enabled — Nous Portal paid access is required for this backend." + ) + return + + # Pure pre-auth UX rows keep the old gate; managed rows already handled + # by the inline login above. + if provider.get("requires_nous_auth") and not managed_feature: features = get_nous_subscription_features(config, force_fresh=force_fresh) entitled = bool( features.account_info and features.account_info.paid_service_access is True diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 561602c0a..e25fd86f1 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -321,3 +321,70 @@ def test_apply_nous_managed_defaults_preserves_existing_video_gen_section(monkey assert config["video_gen"]["use_gateway"] is True # Pre-existing keys should be preserved assert config["video_gen"]["model"] == "pixverse-v6" + + +# --------------------------------------------------------------------------- +# ensure_nous_portal_access — inline login gate for `hermes tools` +# --------------------------------------------------------------------------- + + +def test_ensure_nous_portal_access_fast_path_when_already_paid(monkeypatch): + """Already-entitled users return True without any login prompt.""" + login_called = {"v": False} + + monkeypatch.setattr( + ns, "get_nous_portal_account_info", + lambda **kw: _account(logged_in=True, paid=True), + ) + + def _login(**kw): + login_called["v"] = True + return True + + monkeypatch.setattr(ns, "_run_nous_portal_login_only", _login) + + assert ns.ensure_nous_portal_access() is True + assert login_called["v"] is False + + +def test_ensure_nous_portal_access_logs_in_then_grants(monkeypatch): + """Logged-out user logs in, then entitlement re-check shows paid access.""" + states = iter([ + _account(logged_in=False, paid=None), # initial check + _account(logged_in=True, paid=True), # after login + ]) + monkeypatch.setattr( + ns, "get_nous_portal_account_info", lambda **kw: next(states), + ) + monkeypatch.setattr(ns, "_run_nous_portal_login_only", lambda **kw: True) + + assert ns.ensure_nous_portal_access() is True + + +def test_ensure_nous_portal_access_returns_false_when_login_declined(monkeypatch): + monkeypatch.setattr( + ns, "get_nous_portal_account_info", + lambda **kw: _account(logged_in=False, paid=None), + ) + monkeypatch.setattr(ns, "_run_nous_portal_login_only", lambda **kw: False) + + assert ns.ensure_nous_portal_access() is False + + +def test_ensure_nous_portal_access_false_when_logged_in_but_unpaid(monkeypatch): + """Logged in already but no paid access — no login attempt, returns False.""" + login_called = {"v": False} + monkeypatch.setattr( + ns, "get_nous_portal_account_info", + lambda **kw: _account(logged_in=True, paid=False), + ) + + def _login(**kw): + login_called["v"] = True + return True + + monkeypatch.setattr(ns, "_run_nous_portal_login_only", _login) + + assert ns.ensure_nous_portal_access() is False + # Already logged in, so no device-code login should be attempted. + assert login_called["v"] is False diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index e93ad8fca..fc2906d73 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -612,6 +612,52 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch) assert providers[0]["name"].startswith("Nous Subscription") +def test_visible_providers_show_nous_subscription_when_logged_out(monkeypatch): + """Nous-managed Tool Gateway rows are always listed, even logged out. + + Selecting one triggers an inline Portal login (entitlement is checked at + selection time, not visibility time). + """ + config = {"model": {"provider": "openrouter"}} + + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_portal_account_info", + lambda: NousPortalAccountInfo( + logged_in=False, + source="none", + fresh=False, + paid_service_access=None, + ), + ) + + providers = _visible_providers(TOOL_CATEGORIES["browser"], config) + + assert any(p["name"].startswith("Nous Subscription") for p in providers) + + +def test_visible_providers_show_nous_subscription_when_paid_access_is_false(monkeypatch): + """Logged-in-but-unpaid users still see the managed rows. + + The paid-access gate moved from visibility to selection time — the row is + shown; ``ensure_nous_portal_access`` blocks activation if still unpaid. + """ + config = {"model": {"provider": "nous"}} + + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_portal_account_info", + lambda: NousPortalAccountInfo( + logged_in=True, + source="jwt", + fresh=False, + paid_service_access=False, + ), + ) + + providers = _visible_providers(TOOL_CATEGORIES["browser"], config) + + assert any(p["name"].startswith("Nous Subscription") for p in providers) + + def test_visible_providers_force_fresh_shows_nous_subscription_after_upgrade(monkeypatch): calls = [] @@ -643,24 +689,6 @@ def test_visible_providers_force_fresh_shows_nous_subscription_after_upgrade(mon assert ("features", True) in calls -def test_visible_providers_hide_nous_subscription_when_paid_access_is_false(monkeypatch): - config = {"model": {"provider": "nous"}} - - monkeypatch.setattr( - "hermes_cli.nous_subscription.get_nous_portal_account_info", - lambda: NousPortalAccountInfo( - logged_in=True, - source="jwt", - fresh=False, - paid_service_access=False, - ), - ) - - providers = _visible_providers(TOOL_CATEGORIES["browser"], config) - - assert all(not provider["name"].startswith("Nous Subscription") for provider in providers) - - def test_local_browser_provider_is_saved_explicitly(monkeypatch): config = {} local_provider = next( @@ -669,7 +697,6 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch): if provider.get("browser_provider") == "local" ) monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None) - _configure_provider(local_provider, config) assert config["browser"]["cloud_provider"] == "local" @@ -1265,7 +1292,13 @@ def test_get_effective_configurable_toolsets_dedupes_bundled_plugins(): ({"name": "B", "browser_provider": "browserbase", "env_vars": []}, "browser", False), ({"name": "W", "web_backend": "tavily", "env_vars": []}, "web", False), ]) -def test_reconfigure_provider_syncs_use_gateway(provider, config_key, expected): +def test_reconfigure_provider_syncs_use_gateway(monkeypatch, provider, config_key, expected): + # Managed providers run the inline Portal entitlement gate; treat the user + # as already entitled so the test exercises the use_gateway sync. + monkeypatch.setattr( + "hermes_cli.nous_subscription.ensure_nous_portal_access", + lambda **kwargs: True, + ) config = {} _reconfigure_provider(provider, config) assert config[config_key]["use_gateway"] is expected @@ -1301,3 +1334,69 @@ def test_reconfigure_provider_runs_post_setup_for_env_var_providers( _reconfigure_provider(provider, {}) assert called == [post_setup_key] + + +# --------------------------------------------------------------------------- +# Inline Nous Portal login gate on managed-provider selection +# --------------------------------------------------------------------------- + + +def test_configure_managed_provider_blocks_when_not_entitled(monkeypatch): + """Selecting a Nous-managed backend without paid access writes no config.""" + monkeypatch.setattr( + "hermes_cli.nous_subscription.ensure_nous_portal_access", + lambda **kwargs: False, + ) + provider = { + "name": "Nous Subscription (Firecrawl)", + "web_backend": "firecrawl", + "managed_nous_feature": "web", + "env_vars": [], + } + config = {} + + _configure_provider(provider, config) + + # No use_gateway / backend written — the gate returned before any mutation. + assert "web" not in config + + +def test_configure_managed_provider_enables_when_entitled(monkeypatch): + """Once entitled, selecting the managed backend sets use_gateway=True.""" + monkeypatch.setattr( + "hermes_cli.nous_subscription.ensure_nous_portal_access", + lambda **kwargs: True, + ) + provider = { + "name": "Nous Subscription (Firecrawl)", + "web_backend": "firecrawl", + "managed_nous_feature": "web", + "env_vars": [], + } + config = {} + + _configure_provider(provider, config) + + assert config["web"]["backend"] == "firecrawl" + assert config["web"]["use_gateway"] is True + + +def test_configure_non_managed_provider_skips_portal_gate(monkeypatch): + """A self-hosted provider must never trigger the Nous Portal login gate.""" + called = {"gate": False} + + def _boom(**kwargs): + called["gate"] = True + return False + + monkeypatch.setattr( + "hermes_cli.nous_subscription.ensure_nous_portal_access", _boom + ) + provider = {"name": "Tavily", "web_backend": "tavily", "env_vars": []} + config = {} + + _configure_provider(provider, config) + + assert called["gate"] is False + assert config["web"]["backend"] == "tavily" + assert config["web"]["use_gateway"] is False diff --git a/website/docs/guides/run-hermes-with-nous-portal.md b/website/docs/guides/run-hermes-with-nous-portal.md index a8ac20478..c810d1e1c 100644 --- a/website/docs/guides/run-hermes-with-nous-portal.md +++ b/website/docs/guides/run-hermes-with-nous-portal.md @@ -136,6 +136,8 @@ hermes tools # → TTS → "Nous Subscription" (recommended) ``` +These rows appear in `hermes tools` even before you've logged into Nous Portal — if you pick "Nous Subscription" without an active session, Hermes runs the Portal login inline (without changing your inference provider or your other tools). + Verify your mix with: ```bash diff --git a/website/docs/integrations/nous-portal.md b/website/docs/integrations/nous-portal.md index ddf688d87..24e914793 100644 --- a/website/docs/integrations/nous-portal.md +++ b/website/docs/integrations/nous-portal.md @@ -188,7 +188,7 @@ hermes tools # → TTS → "Nous Subscription" ``` -The Tool Gateway is opt-in per tool, not all-or-nothing. See the [Tool Gateway docs](/user-guide/features/tool-gateway) for the full per-tool configuration matrix. +The Tool Gateway is opt-in per tool, not all-or-nothing. The managed backends show up in `hermes tools` whether or not you're logged into Nous Portal — if you pick "Nous Subscription" before authenticating, Hermes runs the Portal login inline (it won't change your inference provider or touch your other tools). See the [Tool Gateway docs](/user-guide/features/tool-gateway) for the full per-tool configuration matrix. ### Subscription management diff --git a/website/docs/user-guide/features/tool-gateway.md b/website/docs/user-guide/features/tool-gateway.md index 6e7a528d7..edb93b0f6 100644 --- a/website/docs/user-guide/features/tool-gateway.md +++ b/website/docs/user-guide/features/tool-gateway.md @@ -39,19 +39,23 @@ Bring your own keys anytime — per-tool, whenever you want to. The gateway isn' ## Get started -The fastest path for a fresh install: +There are three ways in — pick whichever fits where you are: ```bash -hermes setup --portal # Nous OAuth, set Nous as provider, and turn on the Tool Gateway in one go +hermes setup --portal # Fresh install: Nous OAuth + set Nous as provider + turn on the Tool Gateway in one go ``` -Already have Hermes configured? Just switch your provider: - ```bash -hermes model # Pick Nous Portal — Hermes will offer to turn on the Tool Gateway +hermes model # Switch your inference provider to Nous Portal — Hermes then offers to turn on the gateway for all tools ``` -When you select Nous Portal, Hermes offers to turn on the Tool Gateway. Accept, and you're done — every supported tool is live on the next run. +```bash +hermes tools # Enable the gateway per-tool — pick "Nous Subscription" for any tool you want +``` + +`hermes setup --portal` and `hermes model` are the all-at-once paths: log in once, optionally flip every tool to the gateway. `hermes tools` is the à la carte path — turn on just the tools you want, one at a time. + +**You don't have to log in first.** With `hermes tools`, the Nous-managed backends (Web search, Image, Video, TTS, Browser) are always listed, even if you've never signed into Nous Portal. Select one and Hermes runs the Portal login right there if you aren't already authenticated — no need to run `hermes model` beforehand. If your Nous OAuth is already active, selecting the backend enables it immediately with no extra prompt. This path only logs you in and turns on the one tool you picked — it does **not** switch your inference provider, and it does **not** prompt you to enable the gateway for every other tool. Check what's active at any time: @@ -92,7 +96,7 @@ Switch any tool at any time via: hermes tools # Interactive picker for each tool category ``` -Select the tool, pick **Nous Subscription** as the provider (or any direct provider you prefer). No config editing required. +Select the tool, pick **Nous Subscription** as the provider (or any direct provider you prefer). No config editing required. If you aren't logged into Nous Portal yet, picking **Nous Subscription** kicks off the Portal login inline — you don't need to authenticate through `hermes model` first. ## Using individual image models