diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 33bc325ee..7eae4d479 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -23,7 +23,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Dict, Any, Optional, List, Tuple -from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -1646,14 +1645,8 @@ OPTIONAL_ENV_VARS = { }, } -if not _managed_nous_tools_enabled(): - for _hidden_var in ( - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - ): - OPTIONAL_ENV_VARS.pop(_hidden_var, None) +# Tool Gateway env vars are always visible — they're useful for +# self-hosted / custom gateway setups regardless of subscription state. def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 4880171fd..b05295f1e 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -166,6 +166,7 @@ def curses_radiolist( selected: int = 0, *, cancel_returns: int | None = None, + description: str | None = None, ) -> int: """Curses single-select radio list. Returns the selected index. @@ -174,6 +175,9 @@ def curses_radiolist( items: Display labels for each row. selected: Index that starts selected (pre-selected). cancel_returns: Returned on ESC/q. Defaults to the original *selected*. + description: Optional multi-line text shown between the title and + the item list. Useful for context that should survive the + curses screen clear. """ if cancel_returns is None: cancel_returns = selected @@ -181,6 +185,10 @@ def curses_radiolist( if not sys.stdin.isatty(): return cancel_returns + desc_lines: list[str] = [] + if description: + desc_lines = description.splitlines() + try: import curses result_holder: list = [None] @@ -199,22 +207,35 @@ def curses_radiolist( stdscr.clear() max_y, max_x = stdscr.getmaxyx() + row = 0 + # Header try: hattr = curses.A_BOLD if curses.has_colors(): hattr |= curses.color_pair(2) - stdscr.addnstr(0, 0, title, max_x - 1, hattr) + stdscr.addnstr(row, 0, title, max_x - 1, hattr) + row += 1 + + # Description lines + for dline in desc_lines: + if row >= max_y - 1: + break + stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL) + row += 1 + stdscr.addnstr( - 1, 0, + row, 0, " \u2191\u2193 navigate ENTER/SPACE select ESC cancel", max_x - 1, curses.A_DIM, ) + row += 1 except curses.error: pass # Scrollable item list - visible_rows = max_y - 4 + items_start = row + 1 + visible_rows = max_y - items_start - 1 if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: @@ -223,7 +244,7 @@ def curses_radiolist( for draw_i, i in enumerate( range(scroll_offset, min(len(items), scroll_offset + visible_rows)) ): - y = draw_i + 3 + y = draw_i + items_start if y >= max_y - 1: break radio = "\u25cf" if i == selected else "\u25cb" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3eedcf7fc..33d017d8c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1277,11 +1277,8 @@ def _model_flow_nous(config, current_model="", args=None): AuthError, format_auth_error, _login_nous, PROVIDER_REGISTRY, ) - from hermes_cli.config import get_env_value, save_config, save_env_value - from hermes_cli.nous_subscription import ( - apply_nous_provider_defaults, - get_nous_subscription_explainer_lines, - ) + from hermes_cli.config import get_env_value, load_config, save_config, save_env_value + from hermes_cli.nous_subscription import prompt_enable_tool_gateway import argparse state = get_provider_auth_state("nous") @@ -1300,9 +1297,12 @@ def _model_flow_nous(config, current_model="", args=None): insecure=bool(getattr(args, "insecure", False)), ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) - print() - for line in get_nous_subscription_explainer_lines(): - print(line) + # Offer Tool Gateway enablement for paid subscribers + try: + _refreshed = load_config() or {} + prompt_enable_tool_gateway(_refreshed) + except Exception: + pass except SystemExit: print("Login cancelled or failed.") return @@ -1410,18 +1410,10 @@ def _model_flow_nous(config, current_model="", args=None): if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - changed_defaults = apply_nous_provider_defaults(config) save_config(config) print(f"Default model set to: {selected} (via Nous Portal)") - if "tts" in changed_defaults: - print("TTS provider set to: OpenAI TTS via your Nous subscription") - else: - current_tts = str(config.get("tts", {}).get("provider") or "edge") - if current_tts.lower() not in {"", "edge"}: - print(f"Keeping your existing TTS provider: {current_tts}") - print() - for line in get_nous_subscription_explainer_lines(): - print(line) + # Offer Tool Gateway enablement for paid subscribers + prompt_enable_tool_gateway(config) else: print("No change.") diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index e182b37e7..691126a4c 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -258,6 +258,15 @@ def get_nous_subscription_features( terminal_cfg.get("modal_mode") ) + # use_gateway flags — when True, the user explicitly opted into the + # Tool Gateway via `hermes model`, so direct credentials should NOT + # prevent gateway routing. + web_use_gateway = bool(web_cfg.get("use_gateway")) + tts_use_gateway = bool(tts_cfg.get("use_gateway")) + browser_use_gateway = bool(browser_cfg.get("use_gateway")) + image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {} + image_use_gateway = bool(image_gen_cfg.get("use_gateway")) + direct_exa = bool(get_env_value("EXA_API_KEY")) direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) direct_parallel = bool(get_env_value("PARALLEL_API_KEY")) @@ -270,6 +279,21 @@ def get_nous_subscription_features( direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) direct_modal = has_direct_modal_credentials() + # When use_gateway is set, suppress direct credentials for managed detection + if web_use_gateway: + direct_firecrawl = False + direct_exa = False + direct_parallel = False + direct_tavily = False + if image_use_gateway: + direct_fal = False + if tts_use_gateway: + direct_openai_tts = False + direct_elevenlabs = False + if browser_use_gateway: + direct_browser_use = False + direct_browserbase = False + managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl") managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue") managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio") @@ -440,37 +464,7 @@ def get_nous_subscription_features( ) -def get_nous_subscription_explainer_lines() -> list[str]: - if not managed_nous_tools_enabled(): - return [] - return [ - "Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.", - "Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.", - "Change these later with: hermes setup tools, hermes setup terminal, or hermes status.", - ] - - -def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]: - """Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`.""" - if not managed_nous_tools_enabled(): - return set() - - features = get_nous_subscription_features(config) - if not features.provider_is_nous: - return set() - - tts_cfg = config.get("tts") - if not isinstance(tts_cfg, dict): - tts_cfg = {} - config["tts"] = tts_cfg - - current_tts = str(tts_cfg.get("provider") or "edge").strip().lower() - if current_tts not in {"", "edge"}: - return set() - - tts_cfg["provider"] = "openai" - return {"tts"} def apply_nous_managed_defaults( @@ -530,3 +524,255 @@ def apply_nous_managed_defaults( changed.add("image_gen") return changed + + +# --------------------------------------------------------------------------- +# Tool Gateway offer — single Y/n prompt after model selection +# --------------------------------------------------------------------------- + +_GATEWAY_TOOL_LABELS = { + "web": "Web search & extract (Firecrawl)", + "image_gen": "Image generation (FAL)", + "tts": "Text-to-speech (OpenAI TTS)", + "browser": "Browser automation (Browser Use)", +} + + +def _get_gateway_direct_credentials() -> Dict[str, bool]: + """Return a dict of tool_key -> has_direct_credentials.""" + return { + "web": bool( + get_env_value("FIRECRAWL_API_KEY") + or get_env_value("FIRECRAWL_API_URL") + or get_env_value("PARALLEL_API_KEY") + or get_env_value("TAVILY_API_KEY") + or get_env_value("EXA_API_KEY") + ), + "image_gen": bool(get_env_value("FAL_KEY")), + "tts": bool( + resolve_openai_audio_api_key() + or get_env_value("ELEVENLABS_API_KEY") + ), + "browser": bool( + get_env_value("BROWSER_USE_API_KEY") + or (get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) + ), + } + + +_GATEWAY_DIRECT_LABELS = { + "web": "Firecrawl/Exa/Parallel/Tavily key", + "image_gen": "FAL key", + "tts": "OpenAI/ElevenLabs key", + "browser": "Browser Use/Browserbase key", +} + +_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser") + + +def get_gateway_eligible_tools( + config: Optional[Dict[str, object]] = None, +) -> tuple[list[str], list[str], list[str]]: + """Return (unconfigured, has_direct, already_managed) tool key lists. + + - unconfigured: tools with no direct credentials (easy switch) + - has_direct: tools where the user has their own API keys + - already_managed: tools already routed through the gateway + + All lists are empty when the user is not a paid Nous subscriber or + is not using Nous as their provider. + """ + if not managed_nous_tools_enabled(): + return [], [], [] + + if config is None: + from hermes_cli.config import load_config + config = load_config() or {} + + # Quick provider check without the heavy get_nous_subscription_features call + model_cfg = config.get("model") + if not isinstance(model_cfg, dict) or str(model_cfg.get("provider") or "").strip().lower() != "nous": + return [], [], [] + + direct = _get_gateway_direct_credentials() + + # Check which tools the user has explicitly opted into the gateway for. + # This is distinct from managed_by_nous which fires implicitly when + # no direct keys exist — we only skip the prompt for tools where + # use_gateway was explicitly set. + opted_in = { + "web": bool((config.get("web") if isinstance(config.get("web"), dict) else {}).get("use_gateway")), + "image_gen": bool((config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}).get("use_gateway")), + "tts": bool((config.get("tts") if isinstance(config.get("tts"), dict) else {}).get("use_gateway")), + "browser": bool((config.get("browser") if isinstance(config.get("browser"), dict) else {}).get("use_gateway")), + } + + unconfigured: list[str] = [] + has_direct: list[str] = [] + already_managed: list[str] = [] + for key in _ALL_GATEWAY_KEYS: + if opted_in.get(key): + already_managed.append(key) + elif direct.get(key): + has_direct.append(key) + else: + unconfigured.append(key) + return unconfigured, has_direct, already_managed + + +def apply_gateway_defaults( + config: Dict[str, object], + tool_keys: list[str], +) -> set[str]: + """Apply Tool Gateway config for the given tool keys. + + Sets ``use_gateway: true`` in each tool's config section so the + runtime prefers the gateway even when direct API keys are present. + + Returns the set of tools that were actually changed. + """ + changed: set[str] = set() + + web_cfg = config.get("web") + if not isinstance(web_cfg, dict): + web_cfg = {} + config["web"] = web_cfg + + tts_cfg = config.get("tts") + if not isinstance(tts_cfg, dict): + tts_cfg = {} + config["tts"] = tts_cfg + + browser_cfg = config.get("browser") + if not isinstance(browser_cfg, dict): + browser_cfg = {} + config["browser"] = browser_cfg + + if "web" in tool_keys: + web_cfg["backend"] = "firecrawl" + web_cfg["use_gateway"] = True + changed.add("web") + + if "tts" in tool_keys: + tts_cfg["provider"] = "openai" + tts_cfg["use_gateway"] = True + changed.add("tts") + + if "browser" in tool_keys: + browser_cfg["cloud_provider"] = "browser-use" + browser_cfg["use_gateway"] = True + changed.add("browser") + + if "image_gen" in tool_keys: + image_cfg = config.get("image_gen") + if not isinstance(image_cfg, dict): + image_cfg = {} + config["image_gen"] = image_cfg + image_cfg["use_gateway"] = True + changed.add("image_gen") + + return changed + + +def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]: + """If eligible tools exist, prompt the user to enable the Tool Gateway. + + Uses prompt_choice() with a description parameter so the curses TUI + shows the tool context alongside the choices. + + Returns the set of tools that were enabled, or empty set if the user + declined or no tools were eligible. + """ + unconfigured, has_direct, already_managed = get_gateway_eligible_tools(config) + if not unconfigured and not has_direct: + return set() + + try: + from hermes_cli.setup import prompt_choice + except Exception: + return set() + + # Build description lines showing full status of all gateway tools + desc_parts: list[str] = [ + "", + " The Tool Gateway gives you access to web search, image generation,", + " text-to-speech, and browser automation through your Nous subscription.", + " No need to sign up for separate API keys — just pick the tools you want.", + "", + ] + if already_managed: + for k in already_managed: + desc_parts.append(f" ✓ {_GATEWAY_TOOL_LABELS[k]} — using Tool Gateway") + if unconfigured: + for k in unconfigured: + desc_parts.append(f" ○ {_GATEWAY_TOOL_LABELS[k]} — not configured") + if has_direct: + for k in has_direct: + desc_parts.append(f" ○ {_GATEWAY_TOOL_LABELS[k]} — using {_GATEWAY_DIRECT_LABELS[k]}") + + # Build short choice labels — detail is in the description above + choices: list[str] = [] + choice_keys: list[str] = [] # maps choice index -> action + + if unconfigured and has_direct: + choices.append("Enable for all tools (existing keys kept, not used)") + choice_keys.append("all") + + choices.append("Enable only for tools without existing keys") + choice_keys.append("unconfigured") + + choices.append("Skip") + choice_keys.append("skip") + + elif unconfigured: + choices.append("Enable Tool Gateway") + choice_keys.append("unconfigured") + + choices.append("Skip") + choice_keys.append("skip") + + else: + choices.append("Enable Tool Gateway (existing keys kept, not used)") + choice_keys.append("all") + + choices.append("Skip") + choice_keys.append("skip") + + description = "\n".join(desc_parts) if desc_parts else None + # Default to "Enable" when user has no direct keys (new user), + # default to "Skip" when they have existing keys to preserve. + default_idx = 0 if not has_direct else len(choices) - 1 + + try: + idx = prompt_choice( + "Your Nous subscription includes the Tool Gateway.", + choices, + default_idx, + description=description, + ) + except (KeyboardInterrupt, EOFError, OSError, SystemExit): + return set() + + action = choice_keys[idx] + if action == "skip": + return set() + + if action == "all": + # Apply to switchable tools + ensure already-managed tools also + # have use_gateway persisted in config for consistency. + to_apply = list(_ALL_GATEWAY_KEYS) + else: + to_apply = unconfigured + + changed = apply_gateway_defaults(config, to_apply) + if changed: + from hermes_cli.config import save_config + save_config(config) + # Only report the tools that actually switched (not already-managed ones) + newly_switched = changed - set(already_managed) + for key in sorted(newly_switched): + label = _GATEWAY_TOOL_LABELS.get(key, key) + print(f" ✓ {label}: enabled via Nous subscription") + if already_managed and not newly_switched: + print(" (all tools already using Tool Gateway)") + return changed diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index eafe3b633..96ee77112 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -20,10 +20,7 @@ import copy from pathlib import Path from typing import Optional, Dict, Any -from hermes_cli.nous_subscription import ( - apply_nous_provider_defaults, - get_nous_subscription_features, -) +from hermes_cli.nous_subscription import get_nous_subscription_features from tools.tool_backend_helpers import managed_nous_tools_enabled from hermes_constants import get_optional_skills_dir @@ -213,20 +210,20 @@ def prompt(question: str, default: str = None, password: bool = False) -> str: sys.exit(1) -def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int: +def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int: """Single-select menu using curses. Delegates to curses_radiolist.""" from hermes_cli.curses_ui import curses_radiolist - return curses_radiolist(question, choices, selected=default, cancel_returns=-1) + return curses_radiolist(question, choices, selected=default, cancel_returns=-1, description=description) -def prompt_choice(question: str, choices: list, default: int = 0) -> int: +def prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int: """Prompt for a choice from a list with arrow key navigation. Escape keeps the current default (skips the question). Ctrl+C exits the wizard. """ - idx = _curses_prompt_choice(question, choices, default) + idx = _curses_prompt_choice(question, choices, default, description=description) if idx >= 0: if idx == default: print_info(" Skipped (keeping current)") @@ -835,14 +832,7 @@ def setup_model_provider(config: dict, *, quick: bool = False): print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings") - 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}") - + # Tool Gateway prompt is already shown by _model_flow_nous() above. save_config(config) if not quick and selected_provider != "nous": diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 5ec93f24d..2e34ae9c3 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -212,7 +212,7 @@ def show_status(args): if managed_nous_tools_enabled(): features = get_nous_subscription_features(config) print() - print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD)) + print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD)) if not features.nous_auth_present: print(" Nous Portal ✗ not logged in") else: @@ -230,6 +230,18 @@ def show_status(args): else: state = "not configured" print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") + elif nous_logged_in: + # Logged into Nous but on the free tier — show upgrade nudge + print() + print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD)) + print(" Your free-tier Nous account does not include Tool Gateway access.") + print(" Upgrade your subscription to unlock managed web, image, TTS, and browser tools.") + try: + portal_url = nous_status.get("portal_base_url", "").rstrip("/") + if portal_url: + print(f" Upgrade: {portal_url}") + except Exception: + pass # ========================================================================= # API-Key Providers diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 0609e7ff4..fa15fe087 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -954,34 +954,49 @@ def _configure_provider(provider: dict, config: dict): # Set TTS provider in config if applicable if provider.get("tts_provider"): - config.setdefault("tts", {})["provider"] = provider["tts_provider"] + tts_cfg = config.setdefault("tts", {}) + tts_cfg["provider"] = provider["tts_provider"] + tts_cfg["use_gateway"] = bool(managed_feature) # Set browser cloud provider in config if applicable if "browser_provider" in provider: bp = provider["browser_provider"] + browser_cfg = config.setdefault("browser", {}) if bp == "local": - config.setdefault("browser", {})["cloud_provider"] = "local" + browser_cfg["cloud_provider"] = "local" _print_success(" Browser set to local mode") elif bp: - config.setdefault("browser", {})["cloud_provider"] = bp + browser_cfg["cloud_provider"] = bp _print_success(f" Browser cloud provider set to: {bp}") + browser_cfg["use_gateway"] = bool(managed_feature) # Set web search backend in config if applicable if provider.get("web_backend"): - config.setdefault("web", {})["backend"] = provider["web_backend"] + web_cfg = config.setdefault("web", {}) + web_cfg["backend"] = provider["web_backend"] + web_cfg["use_gateway"] = bool(managed_feature) _print_success(f" Web backend set to: {provider['web_backend']}") + # For tools without a specific config key (e.g. image_gen), still + # track use_gateway so the runtime knows the user's intent. + if managed_feature and managed_feature not in ("web", "tts", "browser"): + config.setdefault(managed_feature, {})["use_gateway"] = True + elif not managed_feature: + # User picked a non-gateway provider — find which category this + # belongs to and clear use_gateway if it was previously set. + for cat_key, cat in TOOL_CATEGORIES.items(): + if provider in cat.get("providers", []): + section = config.get(cat_key) + if isinstance(section, dict) and section.get("use_gateway"): + section["use_gateway"] = False + break + if not env_vars: if provider.get("post_setup"): _run_post_setup(provider["post_setup"]) _print_success(f" {provider['name']} - no configuration needed!") if managed_feature: _print_info(" Requests for this tool will be billed to your Nous subscription.") - override_envs = provider.get("override_env_vars", []) - if any(get_env_value(env_var) for env_var in override_envs): - _print_warning( - " Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env." - ) return # Prompt for each required env var @@ -1187,11 +1202,6 @@ def _reconfigure_provider(provider: dict, config: dict): _print_success(f" {provider['name']} - no configuration needed!") if managed_feature: _print_info(" Requests for this tool will be billed to your Nous subscription.") - override_envs = provider.get("override_env_vars", []) - if any(get_env_value(env_var) for env_var in override_envs): - _print_warning( - " Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env." - ) return for var in env_vars: diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 5a222cc38..2b231d2d1 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -413,7 +413,7 @@ class TestBuildSkillsSystemPrompt: class TestBuildNousSubscriptionPrompt: def test_includes_active_subscription_features(self, monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) monkeypatch.setattr( "hermes_cli.nous_subscription.get_nous_subscription_features", lambda config=None: NousSubscriptionFeatures( @@ -437,7 +437,7 @@ class TestBuildNousSubscriptionPrompt: assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys" in prompt def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) monkeypatch.setattr( "hermes_cli.nous_subscription.get_nous_subscription_features", lambda config=None: NousSubscriptionFeatures( @@ -460,7 +460,7 @@ class TestBuildNousSubscriptionPrompt: assert "Do not mention subscription unless" in prompt def test_feature_flag_off_returns_empty_prompt(self, monkeypatch): - monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: False) prompt = build_nous_subscription_prompt({"web_search"}) diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 624e166a8..fe4153c80 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -308,7 +308,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch): def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True) config = { "model": {"provider": "nous", "default": "claude-opus-4-6"}, "tts": {"provider": "elevenlabs"}, @@ -333,21 +333,17 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_ monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6") monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) - monkeypatch.setattr( - "hermes_cli.nous_subscription.get_nous_subscription_explainer_lines", - lambda: ["Nous subscription enables managed web tools."], - ) hermes_main._model_flow_nous(config, current_model="claude-opus-4-6") out = capsys.readouterr().out - assert "Nous subscription enables managed web tools." in out + assert "Default model set to:" in out assert config["tts"]["provider"] == "elevenlabs" assert config["browser"]["cloud_provider"] == "browser-use" -def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") +def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys): + monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True) config = { "model": {"provider": "nous", "default": "claude-opus-4-6"}, "tts": {"provider": "edge"}, @@ -355,13 +351,13 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat monkeypatch.setattr( "hermes_cli.auth.get_provider_auth_state", - lambda provider: {"access_token": "nous-token"}, + lambda provider: {"access_token": "***"}, ) monkeypatch.setattr( "hermes_cli.auth.resolve_nous_runtime_credentials", lambda *args, **kwargs: { "base_url": "https://inference.example.com/v1", - "api_key": "nous-key", + "api_key": "***", }, ) monkeypatch.setattr( @@ -371,17 +367,12 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6") monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) - monkeypatch.setattr( - "hermes_cli.nous_subscription.get_nous_subscription_explainer_lines", - lambda: ["Nous subscription enables managed web tools."], - ) - hermes_main._model_flow_nous(config, current_model="claude-opus-4-6") out = capsys.readouterr().out - assert "Nous subscription enables managed web tools." in out - assert "OpenAI TTS via your Nous subscription" in out - assert config["tts"]["provider"] == "openai" + # Tool Gateway prompt should be shown (input() raises OSError in pytest + # which is caught, so the prompt text appears but nothing is applied) + assert "Tool Gateway" in out def test_codex_provider_uses_config_model(monkeypatch): diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index c04276976..b7819cfa8 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -24,7 +24,7 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) monkeypatch.setattr(ns, "get_env_value", lambda name: "") monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True}) monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True) diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 2c07d3d66..150fddab0 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -363,7 +363,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config = load_config() @@ -405,7 +405,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index 04221d88f..d9f860153 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -64,7 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: True) from hermes_cli import status as status_mod _patch_common_status_deps(monkeypatch, status_mod, tmp_path) @@ -98,13 +98,13 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path status_mod.show_status(SimpleNamespace(all=False, deep=False)) out = capsys.readouterr().out - assert "Nous Subscription Features" in out + assert "Nous Tool Gateway" in out assert "Browser automation" in out assert "active via Nous subscription" in out def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path): - monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False) from hermes_cli import status as status_mod _patch_common_status_deps(monkeypatch, status_mod, tmp_path) @@ -121,4 +121,4 @@ def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(mo status_mod.show_status(SimpleNamespace(all=False, deep=False)) out = capsys.readouterr().out - assert "Nous Subscription Features" not in out + assert "Nous Tool Gateway" not in out diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 3ad0be886..09765c440 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -296,7 +296,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present() def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True) config = {"model": {"provider": "nous"}} monkeypatch.setattr( @@ -310,7 +310,7 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch) def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch): - monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: False) config = {"model": {"provider": "nous"}} monkeypatch.setattr( @@ -338,7 +338,8 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch): def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True) + monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True) config = { "model": {"provider": "nous"}, "platform_toolsets": {"cli": []}, diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py index 5ae24f01a..6c963be62 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -47,7 +47,15 @@ def _restore_tool_and_agent_modules(): @pytest.fixture(autouse=True) def _enable_managed_nous_tools(monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + """Ensure managed_nous_tools_enabled() returns True even after module reloads. + + The _install_fake_tools_package() helper resets and reimports tool modules, + so a simple monkeypatch on tool_backend_helpers doesn't survive. We patch + the *source* modules that the reimported modules will import from — both + hermes_cli.auth and hermes_cli.models — so the function body returns True. + """ + monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True}) + monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False) def _install_fake_tools_package(): diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index ecbf71c2a..4468dfe94 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -46,7 +46,10 @@ def _restore_tool_and_agent_modules(): @pytest.fixture(autouse=True) def _enable_managed_nous_tools(monkeypatch): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + """Patch the source modules so managed_nous_tools_enabled() returns True + even after tool modules are dynamically reloaded.""" + monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True}) + monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False) def _install_fake_tools_package(): diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index f854732b2..a539fb57c 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -19,11 +19,10 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain() with patch.dict( os.environ, { - "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", "TOOL_GATEWAY_DOMAIN": "nousresearch.com", }, clear=False, - ): + ), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): result = resolve_managed_tool_gateway( "firecrawl", token_reader=lambda: "nous-token", @@ -39,11 +38,10 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): with patch.dict( os.environ, { - "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", "BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/", }, clear=False, - ): + ), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): result = resolve_managed_tool_gateway( "browser-use", token_reader=lambda: "nous-token", @@ -57,11 +55,10 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): with patch.dict( os.environ, { - "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", "TOOL_GATEWAY_DOMAIN": "nousresearch.com", }, clear=False, - ): + ), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True): result = resolve_managed_tool_gateway( "firecrawl", token_reader=lambda: None, @@ -70,8 +67,9 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): assert result is None -def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag(): - with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): +def test_resolve_managed_tool_gateway_is_disabled_without_subscription(): + with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \ + patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False): result = resolve_managed_tool_gateway( "firecrawl", token_reader=lambda: "nous-token", diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index aab5c53f5..7859043ab 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -7,7 +7,6 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool") def _clear_terminal_env(monkeypatch): """Remove terminal env vars that could affect requirements checks.""" keys = [ - "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "TERMINAL_ENV", "TERMINAL_MODAL_MODE", "TERMINAL_SSH_HOST", @@ -19,6 +18,11 @@ def _clear_terminal_env(monkeypatch): ] for key in keys: monkeypatch.delenv(key, raising=False) + # Default: no Nous subscription — patch both the terminal_tool local + # binding and tool_backend_helpers (used by resolve_modal_backend_state). + monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: False) + import tools.tool_backend_helpers as _tbh + monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: False) def test_local_terminal_requirements(monkeypatch, caplog): @@ -81,7 +85,9 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path): _clear_terminal_env(monkeypatch) - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True) + import tools.tool_backend_helpers as _tbh + monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) @@ -98,7 +104,9 @@ def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_min def test_modal_backend_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path): _clear_terminal_env(monkeypatch) - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True) + import tools.tool_backend_helpers as _tbh + monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id") monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret") @@ -147,7 +155,7 @@ def test_modal_backend_managed_mode_does_not_fall_back_to_direct(monkeypatch, ca assert ok is False assert any( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage() + "paid Nous subscription is required" in record.getMessage() for record in caplog.records ) @@ -165,6 +173,6 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey assert ok is False assert any( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage() + "paid Nous subscription is required" in record.getMessage() for record in caplog.records ) diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index d21e0628f..1fbaef8e3 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -28,7 +28,8 @@ class TestTerminalRequirements: assert {"read_file", "write_file", "patch", "search_files"}.issubset(names) def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True) + monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True) monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) diff --git a/tests/tools/test_tool_backend_helpers.py b/tests/tools/test_tool_backend_helpers.py index faaed9c5e..abe6d7bd1 100644 --- a/tests/tools/test_tool_backend_helpers.py +++ b/tests/tools/test_tool_backend_helpers.py @@ -1,7 +1,7 @@ """Unit tests for tools/tool_backend_helpers.py. Tests cover: -- managed_nous_tools_enabled() feature flag +- managed_nous_tools_enabled() subscription-based gate - normalize_browser_cloud_provider() coercion - coerce_modal_mode() / normalize_modal_mode() validation - has_direct_modal_credentials() detection @@ -27,24 +27,51 @@ from tools.tool_backend_helpers import ( ) +def _raise_import(): + raise ImportError("simulated missing module") + + # --------------------------------------------------------------------------- # managed_nous_tools_enabled # --------------------------------------------------------------------------- class TestManagedNousToolsEnabled: - """Feature flag driven by HERMES_ENABLE_NOUS_MANAGED_TOOLS.""" + """Subscription-based gate: True for paid Nous subscribers.""" - def test_disabled_by_default(self, monkeypatch): - monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + def test_disabled_when_not_logged_in(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.get_nous_auth_status", + lambda: {}, + ) assert managed_nous_tools_enabled() is False - @pytest.mark.parametrize("val", ["1", "true", "True", "yes"]) - def test_enabled_when_truthy(self, monkeypatch, val): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val) + def test_disabled_for_free_tier(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + monkeypatch.setattr( + "hermes_cli.models.check_nous_free_tier", + lambda: True, + ) + assert managed_nous_tools_enabled() is False + + def test_enabled_for_paid_subscriber(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + monkeypatch.setattr( + "hermes_cli.models.check_nous_free_tier", + lambda: False, + ) assert managed_nous_tools_enabled() is True - @pytest.mark.parametrize("val", ["0", "false", "no", ""]) - def test_disabled_when_falsy(self, monkeypatch, val): - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val) + def test_returns_false_on_exception(self, monkeypatch): + """Should never crash — returns False on any exception.""" + monkeypatch.setattr( + "hermes_cli.auth.get_nous_auth_status", + _raise_import, + ) assert managed_nous_tools_enabled() is False @@ -171,10 +198,10 @@ class TestResolveModalBackendState: @staticmethod def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False): """Helper to call resolve_modal_backend_state with feature flag control.""" - if nous_enabled: - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") - else: - monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "") + monkeypatch.setattr( + "tools.tool_backend_helpers.managed_nous_tools_enabled", + lambda: nous_enabled, + ) return resolve_modal_backend_state( mode, has_direct=has_direct, managed_ready=managed_ready ) diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 9e33d7445..ff9e0d549 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -26,7 +26,6 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -35,7 +34,15 @@ class TestFirecrawlClientConfig: "TOOL_GATEWAY_USER_TOKEN", ): os.environ.pop(key, None) - os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" + # Enable managed tools by default for these tests — patch both the + # local web_tools import and the managed_tool_gateway import so the + # full firecrawl client init path sees True. + self._managed_patchers = [ + patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), + patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + ] + for p in self._managed_patchers: + p.start() def teardown_method(self): """Reset client after each test.""" @@ -43,7 +50,6 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -52,6 +58,8 @@ class TestFirecrawlClientConfig: "TOOL_GATEWAY_USER_TOKEN", ): os.environ.pop(key, None) + for p in self._managed_patchers: + p.stop() # ── Configuration matrix ───────────────────────────────────────── @@ -298,7 +306,6 @@ class TestBackendSelection: """ _ENV_KEYS = ( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", @@ -311,14 +318,20 @@ class TestBackendSelection: ) def setup_method(self): - os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": - os.environ.pop(key, None) + os.environ.pop(key, None) + self._managed_patchers = [ + patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), + patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + ] + for p in self._managed_patchers: + p.start() def teardown_method(self): for key in self._ENV_KEYS: os.environ.pop(key, None) + for p in self._managed_patchers: + p.stop() # ── Config-based selection (web.backend in config.yaml) ─────────── @@ -523,7 +536,6 @@ class TestCheckWebApiKey: """Test suite for check_web_api_key() unified availability check.""" _ENV_KEYS = ( - "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", @@ -536,14 +548,20 @@ class TestCheckWebApiKey: ) def setup_method(self): - os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": - os.environ.pop(key, None) + os.environ.pop(key, None) + self._managed_patchers = [ + patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), + patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + ] + for p in self._managed_patchers: + p.start() def teardown_method(self): for key in self._ENV_KEYS: os.environ.pop(key, None) + for p in self._managed_patchers: + p.stop() def test_parallel_key_only(self): with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}): diff --git a/tools/browser_providers/browser_use.py b/tools/browser_providers/browser_use.py index 0f12dc440..f8e9a8d9f 100644 --- a/tools/browser_providers/browser_use.py +++ b/tools/browser_providers/browser_use.py @@ -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, diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 487b9b8db..db2c5254e 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -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") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 1aa266522..69832cc1c 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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 diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index b65e19174..a770fe747 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -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 ``
.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 diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 65ff725ee..68c0d3c39 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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") diff --git a/tools/web_tools.py b/tools/web_tools.py index 0f21328ec..c24f1fc38 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -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(