feat: ungate Tool Gateway — subscription-based access with per-tool opt-in

Replace the HERMES_ENABLE_NOUS_MANAGED_TOOLS env-var feature flag with
subscription-based detection. The Tool Gateway is now available to any
paid Nous subscriber without needing a hidden env var.

Core changes:
- managed_nous_tools_enabled() checks get_nous_auth_status() +
  check_nous_free_tier() instead of an env var
- New use_gateway config flag per tool section (web, tts, browser,
  image_gen) records explicit user opt-in and overrides direct API
  keys at runtime
- New prefers_gateway(section) shared helper in tool_backend_helpers.py
  used by all 4 tool runtimes (web, tts, image gen, browser)

UX flow:
- hermes model: after Nous login/model selection, shows a curses
  prompt listing all gateway-eligible tools with current status.
  User chooses to enable all, enable only unconfigured tools, or skip.
  Defaults to Enable for new users, Skip when direct keys exist.
- hermes tools: provider selection now manages use_gateway flag —
  selecting Nous Subscription sets it, selecting any other provider
  clears it
- hermes status: renamed section to Nous Tool Gateway, added
  free-tier upgrade nudge for logged-in free users
- curses_radiolist: new description parameter for multi-line context
  that survives the screen clear

Runtime behavior:
- Each tool runtime (web_tools, tts_tool, image_generation_tool,
  browser_use) checks prefers_gateway() before falling back to
  direct env-var credentials
- get_nous_subscription_features() respects use_gateway flags,
  suppressing direct credential detection when the user opted in

Removed:
- HERMES_ENABLE_NOUS_MANAGED_TOOLS env var and all references
- apply_nous_provider_defaults() silent TTS auto-set
- get_nous_subscription_explainer_lines() static text
- Override env var warnings (use_gateway handles this properly now)
This commit is contained in:
emozilla
2026-04-16 01:59:51 -04:00
committed by Teknium
parent 25c7b1baa7
commit f188ac74f0
26 changed files with 544 additions and 187 deletions

View File

@@ -10,7 +10,7 @@ import requests
from tools.browser_providers.base import CloudBrowserProvider
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {}
@@ -75,7 +75,7 @@ class BrowserUseProvider(CloudBrowserProvider):
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
api_key = os.environ.get("BROWSER_USE_API_KEY")
if api_key:
if api_key and not prefers_gateway("browser"):
return {
"api_key": api_key,
"base_url": _BASE_URL,

View File

@@ -39,7 +39,7 @@ from urllib.parse import urlencode
import fal_client
from tools.debug_helpers import DebugSession
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
logger = logging.getLogger(__name__)
@@ -87,8 +87,9 @@ _managed_fal_client_lock = threading.Lock()
def _resolve_managed_fal_gateway():
"""Return managed fal-queue gateway config when direct FAL credentials are absent."""
if os.getenv("FAL_KEY"):
"""Return managed fal-queue gateway config when the user prefers the gateway
or direct FAL credentials are absent."""
if os.getenv("FAL_KEY") and not prefers_gateway("image_gen"):
return None
return resolve_managed_tool_gateway("fal-queue")

View File

@@ -762,8 +762,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag or "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` or "
"choose TERMINAL_MODAL_MODE=direct/auto."
)
if modal_state["mode"] == "managed":
@@ -1577,8 +1577,8 @@ def check_terminal_requirements() -> bool:
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` "
"or choose TERMINAL_MODAL_MODE=direct/auto."
)
return False

View File

@@ -6,7 +6,6 @@ import os
from pathlib import Path
from typing import Any, Dict
from utils import env_var_enabled
_DEFAULT_BROWSER_PROVIDER = "local"
_DEFAULT_MODAL_MODE = "auto"
@@ -14,8 +13,26 @@ _VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled() -> bool:
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
"""Return True when the user has an active paid Nous subscription.
The Tool Gateway is available to any Nous subscriber who is NOT on
the free tier. We intentionally catch all exceptions and return
False — never block the agent startup path.
"""
try:
from hermes_cli.auth import get_nous_auth_status
status = get_nous_auth_status()
if not status.get("logged_in"):
return False
from hermes_cli.models import check_nous_free_tier
if check_nous_free_tier():
return False # free-tier users don't get gateway access
return True
except Exception:
return False
def normalize_browser_cloud_provider(value: object | None) -> str:
@@ -87,3 +104,18 @@ def resolve_openai_audio_api_key() -> str:
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
or os.getenv("OPENAI_API_KEY", "")
).strip()
def prefers_gateway(config_section: str) -> bool:
"""Return True when the user opted into the Tool Gateway for this tool.
Reads ``<section>.use_gateway`` from config.yaml. Never raises.
"""
try:
from hermes_cli.config import load_config
section = (load_config() or {}).get(config_section)
if isinstance(section, dict):
return bool(section.get("use_gateway"))
except Exception:
pass
return False

View File

@@ -44,7 +44,7 @@ from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway, resolve_openai_audio_api_key
from tools.xai_http import hermes_xai_user_agent
# ---------------------------------------------------------------------------
@@ -823,9 +823,13 @@ def check_tts_requirements() -> bool:
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return direct OpenAI audio config or a managed gateway fallback."""
"""Return direct OpenAI audio config or a managed gateway fallback.
When ``tts.use_gateway`` is set in config, the Tool Gateway is preferred
even if direct OpenAI credentials are present.
"""
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
if direct_api_key and not prefers_gateway("tts"):
return direct_api_key, DEFAULT_OPENAI_BASE_URL
managed_gateway = resolve_managed_tool_gateway("openai-audio")

View File

@@ -59,7 +59,7 @@ from tools.managed_tool_gateway import (
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
@@ -165,8 +165,8 @@ def _raise_web_backend_configuration_error() -> None:
)
if managed_nous_tools_enabled():
message += (
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
" With your Nous subscription you can also use the Tool Gateway — "
"run `hermes tools` and select Nous Subscription as the web provider."
)
raise ValueError(message)
@@ -176,8 +176,8 @@ def _firecrawl_backend_help_suffix() -> str:
if not managed_nous_tools_enabled():
return ""
return (
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
", or use the Nous Tool Gateway via your subscription "
"(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)"
)
@@ -205,13 +205,14 @@ def _web_requires_env() -> list[str]:
def _get_firecrawl_client():
"""Get or create Firecrawl client.
Direct Firecrawl takes precedence when explicitly configured. Otherwise
Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers.
When ``web.use_gateway`` is set in config, the Tool Gateway is preferred
even if direct Firecrawl credentials are present. Otherwise direct
Firecrawl takes precedence when explicitly configured.
"""
global _firecrawl_client, _firecrawl_client_config
direct_config = _get_direct_firecrawl_config()
if direct_config is not None:
if direct_config is not None and not prefers_gateway("web"):
kwargs, client_config = direct_config
else:
managed_gateway = resolve_managed_tool_gateway(