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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user