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:
parent
8f4c8e7c82
commit
1fc7bdc5e6
7 changed files with 411 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue