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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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