feat(tools): always show Nous Tool Gateway backends, login on select (#35792)

* feat(tools): always show Nous Tool Gateway backends, login on select

The Nous-managed Tool Gateway rows in `hermes tools` (Firecrawl, OpenAI
TTS, Browser Use, FAL image/video) were hidden unless the user was already
logged into Nous Portal with paid access. Now they are always listed.
Selecting one runs an inline Nous Portal device-code OAuth + entitlement
check — auth only, no inference-provider switch and no bulk 'enable all
tools' prompt (that stays in `hermes model`). The row only activates the
gateway once paid access is confirmed.

- _visible_providers: stop hiding managed_nous_feature rows (incl. those
  also flagged requires_nous_auth); pure pre-auth UX rows still gate on login
- nous_subscription.ensure_nous_portal_access(): auth + entitlement gate
  that preserves the user's active inference provider
- _configure_provider / _reconfigure_provider: run the inline gate for
  managed backends; write config only when entitled
- picker marker: 'via Nous Portal (login on select)' for logged-out users
- _hidden_nous_gateway_message: now a no-op (rows are never hidden)

* docs: hermes tools is a first-class Tool Gateway entry point

The Tool Gateway docs framed `hermes setup --portal` / `hermes model` as
the activation path and only mentioned `hermes tools` for mixing in your
own keys. With the inline-login change, picking a Nous-managed backend in
`hermes tools` is a complete path on its own — it logs you into Nous
Portal on select if needed, without switching your inference provider or
prompting to enable every other tool.

- tool-gateway.md: Get started now lists three peer entry points; new
  paragraph explaining login-on-select and the no-prompt fast path when
  OAuth is already active
- nous-portal.md + run-hermes-with-nous-portal.md: note that managed rows
  appear logged-out and trigger inline login on select
This commit is contained in:
Teknium
2026-05-31 03:39:17 -07:00
committed by GitHub
parent 8f4c8e7c82
commit 1fc7bdc5e6
7 changed files with 411 additions and 63 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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