feat: add managed tool gateway and Nous subscription support

- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
This commit is contained in:
Robin Fernandes
2026-03-26 15:27:27 -07:00
parent cbf195e806
commit 95dc9aaa75
44 changed files with 4567 additions and 423 deletions

View File

@@ -18,6 +18,12 @@ import sys
from pathlib import Path
from typing import Optional, Dict, Any
from hermes_cli.nous_subscription import (
apply_nous_provider_defaults,
get_nous_subscription_explainer_lines,
get_nous_subscription_features,
)
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -52,6 +58,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
config["model"] = model_cfg
def _print_nous_subscription_guidance() -> None:
print()
print_header("Nous Subscription Tools")
for line in get_nous_subscription_explainer_lines():
print_info(line)
# Default model lists per provider — used as fallback when the live
# /models endpoint can't be reached.
_DEFAULT_PROVIDER_MODELS = {
@@ -560,6 +573,7 @@ def _print_setup_summary(config: dict, hermes_home):
print_header("Tool Availability Summary")
tool_status = []
subscription_features = get_nous_subscription_features(config)
# Vision — use the same runtime resolver as the actual vision tools
try:
@@ -581,8 +595,13 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
# Web tools (Parallel, Firecrawl, or Tavily)
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
tool_status.append(("Web Search & Extract", True, None))
if subscription_features.web.managed_by_nous:
tool_status.append(("Web Search & Extract (Nous subscription)", True, None))
elif subscription_features.web.available:
label = "Web Search & Extract"
if subscription_features.web.current_provider:
label = f"Web Search & Extract ({subscription_features.web.current_provider})"
tool_status.append((label, True, None))
else:
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
@@ -595,7 +614,9 @@ def _print_setup_summary(config: dict, hermes_home):
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
).exists()
)
if get_env_value("BROWSERBASE_API_KEY"):
if subscription_features.browser.managed_by_nous:
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
elif subscription_features.browser.current_provider == "Browserbase" and subscription_features.browser.available:
tool_status.append(("Browser Automation (Browserbase)", True, None))
elif _ab_found:
tool_status.append(("Browser Automation (local)", True, None))
@@ -605,16 +626,22 @@ def _print_setup_summary(config: dict, hermes_home):
)
# FAL (image generation)
if get_env_value("FAL_KEY"):
if subscription_features.image_gen.managed_by_nous:
tool_status.append(("Image Generation (Nous subscription)", True, None))
elif subscription_features.image_gen.available:
tool_status.append(("Image Generation", True, None))
else:
tool_status.append(("Image Generation", False, "FAL_KEY"))
# TTS — show configured provider
tts_provider = config.get("tts", {}).get("provider", "edge")
if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
if subscription_features.tts.managed_by_nous:
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"):
elif tts_provider == "openai" and (
get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
):
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
elif tts_provider == "neutts":
try:
@@ -629,6 +656,16 @@ def _print_setup_summary(config: dict, hermes_home):
else:
tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
if subscription_features.modal.managed_by_nous:
tool_status.append(("Modal Execution (Nous subscription)", True, None))
elif config.get("terminal", {}).get("backend") == "modal":
if subscription_features.modal.direct_override:
tool_status.append(("Modal Execution (direct Modal)", True, None))
else:
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
elif subscription_features.nous_auth_present:
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
# Tinker + WandB (RL training)
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
tool_status.append(("RL Training (Tinker)", True, None))
@@ -905,6 +942,7 @@ def setup_model_provider(config: dict):
)
selected_base_url = None # deferred until after model selection
nous_models = [] # populated if Nous login succeeds
nous_subscription_selected = False
if provider_idx == 0: # OpenRouter
selected_provider = "openrouter"
@@ -1000,6 +1038,9 @@ def setup_model_provider(config: dict):
except Exception as e:
logger.debug("Could not fetch Nous models after login: %s", e)
nous_subscription_selected = True
_print_nous_subscription_guidance()
except SystemExit:
print_warning("Nous Portal login was cancelled or failed.")
print_info("You can try again later with: hermes model")
@@ -1773,10 +1814,20 @@ def setup_model_provider(config: dict):
if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None:
_update_config_for_provider(selected_provider, selected_base_url)
if selected_provider == "nous" and nous_subscription_selected:
changed_defaults = apply_nous_provider_defaults(config)
current_tts = str(config.get("tts", {}).get("provider") or "edge")
if "tts" in changed_defaults:
print_success("TTS provider set to: OpenAI TTS via your Nous subscription")
else:
print_info(f"Keeping your existing TTS provider: {current_tts}")
save_config(config)
# Offer TTS provider selection at the end of model setup
_setup_tts_provider(config)
# Offer TTS provider selection at the end of model setup, except when
# Nous subscription defaults are already being applied.
if selected_provider != "nous":
_setup_tts_provider(config)
# =============================================================================
@@ -1844,6 +1895,7 @@ def _setup_tts_provider(config: dict):
"""Interactive TTS provider selection with install flow for NeuTTS."""
tts_config = config.get("tts", {})
current_provider = tts_config.get("provider", "edge")
subscription_features = get_nous_subscription_features(config)
provider_labels = {
"edge": "Edge TTS",
@@ -1858,20 +1910,36 @@ def _setup_tts_provider(config: dict):
print_info(f"Current: {current_label}")
print()
choices = [
"Edge TTS (free, cloud-based, no setup needed)",
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"NeuTTS (local on-device, free, ~300MB model download)",
f"Keep current ({current_label})",
]
idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1)
choices = []
providers = []
if subscription_features.nous_auth_present:
choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
providers.append("nous-openai")
choices.extend(
[
"Edge TTS (free, cloud-based, no setup needed)",
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"NeuTTS (local on-device, free, ~300MB model download)",
]
)
providers.extend(["edge", "elevenlabs", "openai", "neutts"])
choices.append(f"Keep current ({current_label})")
keep_current_idx = len(choices) - 1
idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
if idx == 4: # Keep current
if idx == keep_current_idx:
return
providers = ["edge", "elevenlabs", "openai", "neutts"]
selected = providers[idx]
selected_via_nous = selected == "nous-openai"
if selected == "nous-openai":
selected = "openai"
print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.")
if get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY"):
print_warning(
"Direct OpenAI credentials are still configured and may take precedence until removed from ~/.hermes/.env."
)
if selected == "neutts":
# Check if already installed
@@ -1909,8 +1977,8 @@ def _setup_tts_provider(config: dict):
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "openai":
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY")
elif selected == "openai" and not selected_via_nous:
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
if not existing:
print()
api_key = prompt("OpenAI API key for TTS", password=True)
@@ -2065,63 +2133,99 @@ def setup_terminal_backend(config: dict):
elif selected_backend == "modal":
print_success("Terminal backend: Modal")
print_info("Serverless cloud sandboxes. Each session gets its own container.")
print_info("Requires a Modal account: https://modal.com")
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import normalize_modal_mode
# Check if swe-rex[modal] is installed
try:
__import__("swe_rex")
except ImportError:
print_info("Installing swe-rex[modal]...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[
uv_bin,
"pip",
"install",
"--python",
sys.executable,
"swe-rex[modal]",
],
capture_output=True,
text=True,
)
managed_modal_available = bool(
get_nous_subscription_features(config).nous_auth_present
and is_managed_tool_gateway_ready("modal")
)
modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode"))
use_managed_modal = False
if managed_modal_available:
modal_choices = [
"Use my Nous subscription",
"Use my own Modal account",
]
if modal_mode == "managed":
default_modal_idx = 0
elif modal_mode == "direct":
default_modal_idx = 1
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("swe-rex[modal] installed")
else:
print_warning(
"Install failed — run manually: pip install 'swe-rex[modal]'"
)
default_modal_idx = 1 if get_env_value("MODAL_TOKEN_ID") else 0
modal_mode_idx = prompt_choice(
"Select how Modal execution should be billed:",
modal_choices,
default_modal_idx,
)
use_managed_modal = modal_mode_idx == 0
# Modal token
print()
print_info("Modal authentication:")
print_info(" Get your token at: https://modal.com/settings")
existing_token = get_env_value("MODAL_TOKEN_ID")
if existing_token:
print_info(" Modal token: already configured")
if prompt_yes_no(" Update Modal credentials?", False):
if use_managed_modal:
config["terminal"]["modal_mode"] = "managed"
print_info("Modal execution will use the managed Nous gateway and bill to your subscription.")
if get_env_value("MODAL_TOKEN_ID") or get_env_value("MODAL_TOKEN_SECRET"):
print_info(
"Direct Modal credentials are still configured, but this backend is pinned to managed mode."
)
else:
config["terminal"]["modal_mode"] = "direct"
print_info("Requires a Modal account: https://modal.com")
# Check if swe-rex[modal] is installed
try:
__import__("swe_rex")
except ImportError:
print_info("Installing swe-rex[modal]...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[
uv_bin,
"pip",
"install",
"--python",
sys.executable,
"swe-rex[modal]",
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("swe-rex[modal] installed")
else:
print_warning(
"Install failed — run manually: pip install 'swe-rex[modal]'"
)
# Modal token
print()
print_info("Modal authentication:")
print_info(" Get your token at: https://modal.com/settings")
existing_token = get_env_value("MODAL_TOKEN_ID")
if existing_token:
print_info(" Modal token: already configured")
if prompt_yes_no(" Update Modal credentials?", False):
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
else:
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
else:
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
_prompt_container_resources(config)
@@ -2235,6 +2339,8 @@ def setup_terminal_backend(config: dict):
# Sync terminal backend to .env so terminal_tool picks it up directly.
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
save_env_value("TERMINAL_ENV", selected_backend)
if selected_backend == "modal":
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
save_config(config)
print()
print_success(f"Terminal backend set to: {selected_backend}")
@@ -3089,6 +3195,17 @@ SETUP_SECTIONS = [
("agent", "Agent Settings", setup_agent_settings),
]
# The returning-user menu intentionally omits standalone TTS because model setup
# already includes TTS selection and tools setup covers the rest of the provider
# configuration. Keep this list in the same order as the visible menu entries.
RETURNING_USER_MENU_SECTION_KEYS = [
"model",
"terminal",
"gateway",
"tools",
"agent",
]
def run_setup_wizard(args):
"""Run the interactive setup wizard.
@@ -3237,8 +3354,7 @@ def run_setup_wizard(args):
# Individual section — map by key, not by position.
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
# so positional indexing (choice - 3) would dispatch the wrong section.
_RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"]
section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3]
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
if section:
_, label, func = section