mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
25c7b1baa7
commit
f188ac74f0
26 changed files with 544 additions and 187 deletions
|
|
@ -23,7 +23,6 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
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"
|
_IS_WINDOWS = platform.system() == "Windows"
|
||||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
_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():
|
# Tool Gateway env vars are always visible — they're useful for
|
||||||
for _hidden_var in (
|
# self-hosted / custom gateway setups regardless of subscription state.
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
|
||||||
"TOOL_GATEWAY_DOMAIN",
|
|
||||||
"TOOL_GATEWAY_SCHEME",
|
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
|
||||||
):
|
|
||||||
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ def curses_radiolist(
|
||||||
selected: int = 0,
|
selected: int = 0,
|
||||||
*,
|
*,
|
||||||
cancel_returns: int | None = None,
|
cancel_returns: int | None = None,
|
||||||
|
description: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Curses single-select radio list. Returns the selected index.
|
"""Curses single-select radio list. Returns the selected index.
|
||||||
|
|
||||||
|
|
@ -174,6 +175,9 @@ def curses_radiolist(
|
||||||
items: Display labels for each row.
|
items: Display labels for each row.
|
||||||
selected: Index that starts selected (pre-selected).
|
selected: Index that starts selected (pre-selected).
|
||||||
cancel_returns: Returned on ESC/q. Defaults to the original *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:
|
if cancel_returns is None:
|
||||||
cancel_returns = selected
|
cancel_returns = selected
|
||||||
|
|
@ -181,6 +185,10 @@ def curses_radiolist(
|
||||||
if not sys.stdin.isatty():
|
if not sys.stdin.isatty():
|
||||||
return cancel_returns
|
return cancel_returns
|
||||||
|
|
||||||
|
desc_lines: list[str] = []
|
||||||
|
if description:
|
||||||
|
desc_lines = description.splitlines()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import curses
|
import curses
|
||||||
result_holder: list = [None]
|
result_holder: list = [None]
|
||||||
|
|
@ -199,22 +207,35 @@ def curses_radiolist(
|
||||||
stdscr.clear()
|
stdscr.clear()
|
||||||
max_y, max_x = stdscr.getmaxyx()
|
max_y, max_x = stdscr.getmaxyx()
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
try:
|
try:
|
||||||
hattr = curses.A_BOLD
|
hattr = curses.A_BOLD
|
||||||
if curses.has_colors():
|
if curses.has_colors():
|
||||||
hattr |= curses.color_pair(2)
|
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(
|
stdscr.addnstr(
|
||||||
1, 0,
|
row, 0,
|
||||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||||
max_x - 1, curses.A_DIM,
|
max_x - 1, curses.A_DIM,
|
||||||
)
|
)
|
||||||
|
row += 1
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Scrollable item list
|
# Scrollable item list
|
||||||
visible_rows = max_y - 4
|
items_start = row + 1
|
||||||
|
visible_rows = max_y - items_start - 1
|
||||||
if cursor < scroll_offset:
|
if cursor < scroll_offset:
|
||||||
scroll_offset = cursor
|
scroll_offset = cursor
|
||||||
elif cursor >= scroll_offset + visible_rows:
|
elif cursor >= scroll_offset + visible_rows:
|
||||||
|
|
@ -223,7 +244,7 @@ def curses_radiolist(
|
||||||
for draw_i, i in enumerate(
|
for draw_i, i in enumerate(
|
||||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||||
):
|
):
|
||||||
y = draw_i + 3
|
y = draw_i + items_start
|
||||||
if y >= max_y - 1:
|
if y >= max_y - 1:
|
||||||
break
|
break
|
||||||
radio = "\u25cf" if i == selected else "\u25cb"
|
radio = "\u25cf" if i == selected else "\u25cb"
|
||||||
|
|
|
||||||
|
|
@ -1277,11 +1277,8 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||||
AuthError, format_auth_error,
|
AuthError, format_auth_error,
|
||||||
_login_nous, PROVIDER_REGISTRY,
|
_login_nous, PROVIDER_REGISTRY,
|
||||||
)
|
)
|
||||||
from hermes_cli.config import get_env_value, save_config, save_env_value
|
from hermes_cli.config import get_env_value, load_config, save_config, save_env_value
|
||||||
from hermes_cli.nous_subscription import (
|
from hermes_cli.nous_subscription import prompt_enable_tool_gateway
|
||||||
apply_nous_provider_defaults,
|
|
||||||
get_nous_subscription_explainer_lines,
|
|
||||||
)
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
state = get_provider_auth_state("nous")
|
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)),
|
insecure=bool(getattr(args, "insecure", False)),
|
||||||
)
|
)
|
||||||
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
||||||
print()
|
# Offer Tool Gateway enablement for paid subscribers
|
||||||
for line in get_nous_subscription_explainer_lines():
|
try:
|
||||||
print(line)
|
_refreshed = load_config() or {}
|
||||||
|
prompt_enable_tool_gateway(_refreshed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
print("Login cancelled or failed.")
|
print("Login cancelled or failed.")
|
||||||
return
|
return
|
||||||
|
|
@ -1410,18 +1410,10 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||||
if get_env_value("OPENAI_BASE_URL"):
|
if get_env_value("OPENAI_BASE_URL"):
|
||||||
save_env_value("OPENAI_BASE_URL", "")
|
save_env_value("OPENAI_BASE_URL", "")
|
||||||
save_env_value("OPENAI_API_KEY", "")
|
save_env_value("OPENAI_API_KEY", "")
|
||||||
changed_defaults = apply_nous_provider_defaults(config)
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
print(f"Default model set to: {selected} (via Nous Portal)")
|
print(f"Default model set to: {selected} (via Nous Portal)")
|
||||||
if "tts" in changed_defaults:
|
# Offer Tool Gateway enablement for paid subscribers
|
||||||
print("TTS provider set to: OpenAI TTS via your Nous subscription")
|
prompt_enable_tool_gateway(config)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
print("No change.")
|
print("No change.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,15 @@ def get_nous_subscription_features(
|
||||||
terminal_cfg.get("modal_mode")
|
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_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_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||||
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
|
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_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
|
||||||
direct_modal = has_direct_modal_credentials()
|
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_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_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")
|
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(
|
def apply_nous_managed_defaults(
|
||||||
|
|
@ -530,3 +524,255 @@ def apply_nous_managed_defaults(
|
||||||
changed.add("image_gen")
|
changed.add("image_gen")
|
||||||
|
|
||||||
return changed
|
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
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from hermes_cli.nous_subscription import (
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||||
apply_nous_provider_defaults,
|
|
||||||
get_nous_subscription_features,
|
|
||||||
)
|
|
||||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
from hermes_constants import get_optional_skills_dir
|
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)
|
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."""
|
"""Single-select menu using curses. Delegates to curses_radiolist."""
|
||||||
from hermes_cli.curses_ui import 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.
|
"""Prompt for a choice from a list with arrow key navigation.
|
||||||
|
|
||||||
Escape keeps the current default (skips the question).
|
Escape keeps the current default (skips the question).
|
||||||
Ctrl+C exits the wizard.
|
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 >= 0:
|
||||||
if idx == default:
|
if idx == default:
|
||||||
print_info(" Skipped (keeping current)")
|
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")
|
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
|
||||||
|
|
||||||
|
|
||||||
if selected_provider == "nous" and nous_subscription_selected:
|
# Tool Gateway prompt is already shown by _model_flow_nous() above.
|
||||||
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)
|
save_config(config)
|
||||||
|
|
||||||
if not quick and selected_provider != "nous":
|
if not quick and selected_provider != "nous":
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ def show_status(args):
|
||||||
if managed_nous_tools_enabled():
|
if managed_nous_tools_enabled():
|
||||||
features = get_nous_subscription_features(config)
|
features = get_nous_subscription_features(config)
|
||||||
print()
|
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:
|
if not features.nous_auth_present:
|
||||||
print(" Nous Portal ✗ not logged in")
|
print(" Nous Portal ✗ not logged in")
|
||||||
else:
|
else:
|
||||||
|
|
@ -230,6 +230,18 @@ def show_status(args):
|
||||||
else:
|
else:
|
||||||
state = "not configured"
|
state = "not configured"
|
||||||
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
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
|
# API-Key Providers
|
||||||
|
|
|
||||||
|
|
@ -954,34 +954,49 @@ def _configure_provider(provider: dict, config: dict):
|
||||||
|
|
||||||
# Set TTS provider in config if applicable
|
# Set TTS provider in config if applicable
|
||||||
if provider.get("tts_provider"):
|
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
|
# Set browser cloud provider in config if applicable
|
||||||
if "browser_provider" in provider:
|
if "browser_provider" in provider:
|
||||||
bp = provider["browser_provider"]
|
bp = provider["browser_provider"]
|
||||||
|
browser_cfg = config.setdefault("browser", {})
|
||||||
if bp == "local":
|
if bp == "local":
|
||||||
config.setdefault("browser", {})["cloud_provider"] = "local"
|
browser_cfg["cloud_provider"] = "local"
|
||||||
_print_success(" Browser set to local mode")
|
_print_success(" Browser set to local mode")
|
||||||
elif bp:
|
elif bp:
|
||||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
browser_cfg["cloud_provider"] = bp
|
||||||
_print_success(f" Browser cloud provider set to: {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
|
# Set web search backend in config if applicable
|
||||||
if provider.get("web_backend"):
|
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']}")
|
_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 not env_vars:
|
||||||
if provider.get("post_setup"):
|
if provider.get("post_setup"):
|
||||||
_run_post_setup(provider["post_setup"])
|
_run_post_setup(provider["post_setup"])
|
||||||
_print_success(f" {provider['name']} - no configuration needed!")
|
_print_success(f" {provider['name']} - no configuration needed!")
|
||||||
if managed_feature:
|
if managed_feature:
|
||||||
_print_info(" Requests for this tool will be billed to your Nous subscription.")
|
_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
|
return
|
||||||
|
|
||||||
# Prompt for each required env var
|
# 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!")
|
_print_success(f" {provider['name']} - no configuration needed!")
|
||||||
if managed_feature:
|
if managed_feature:
|
||||||
_print_info(" Requests for this tool will be billed to your Nous subscription.")
|
_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
|
return
|
||||||
|
|
||||||
for var in env_vars:
|
for var in env_vars:
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,7 @@ class TestBuildSkillsSystemPrompt:
|
||||||
|
|
||||||
class TestBuildNousSubscriptionPrompt:
|
class TestBuildNousSubscriptionPrompt:
|
||||||
def test_includes_active_subscription_features(self, monkeypatch):
|
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(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
lambda config=None: NousSubscriptionFeatures(
|
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
|
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):
|
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(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
lambda config=None: NousSubscriptionFeatures(
|
lambda config=None: NousSubscriptionFeatures(
|
||||||
|
|
@ -460,7 +460,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||||
assert "Do not mention subscription unless" in prompt
|
assert "Do not mention subscription unless" in prompt
|
||||||
|
|
||||||
def test_feature_flag_off_returns_empty_prompt(self, monkeypatch):
|
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"})
|
prompt = build_nous_subscription_prompt({"web_search"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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 = {
|
config = {
|
||||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
"tts": {"provider": "elevenlabs"},
|
"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._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._save_model_choice", lambda model: None)
|
||||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: 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")
|
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
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["tts"]["provider"] == "elevenlabs"
|
||||||
assert config["browser"]["cloud_provider"] == "browser-use"
|
assert config["browser"]["cloud_provider"] == "browser-use"
|
||||||
|
|
||||||
|
|
||||||
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
|
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
||||||
config = {
|
config = {
|
||||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
"tts": {"provider": "edge"},
|
"tts": {"provider": "edge"},
|
||||||
|
|
@ -355,13 +351,13 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.auth.get_provider_auth_state",
|
"hermes_cli.auth.get_provider_auth_state",
|
||||||
lambda provider: {"access_token": "nous-token"},
|
lambda provider: {"access_token": "***"},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||||
lambda *args, **kwargs: {
|
lambda *args, **kwargs: {
|
||||||
"base_url": "https://inference.example.com/v1",
|
"base_url": "https://inference.example.com/v1",
|
||||||
"api_key": "nous-key",
|
"api_key": "***",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
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._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._save_model_choice", lambda model: None)
|
||||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: 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")
|
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Nous subscription enables managed web tools." in out
|
# Tool Gateway prompt should be shown (input() raises OSError in pytest
|
||||||
assert "OpenAI TTS via your Nous subscription" in out
|
# which is caught, so the prompt text appears but nothing is applied)
|
||||||
assert config["tts"]["provider"] == "openai"
|
assert "Tool Gateway" in out
|
||||||
|
|
||||||
|
|
||||||
def test_codex_provider_uses_config_model(monkeypatch):
|
def test_codex_provider_uses_config_model(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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_env_value", lambda name: "")
|
||||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
config = load_config()
|
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):
|
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.setenv("HERMES_HOME", str(tmp_path))
|
||||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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
|
from hermes_cli import status as status_mod
|
||||||
|
|
||||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
_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))
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Nous Subscription Features" in out
|
assert "Nous Tool Gateway" in out
|
||||||
assert "Browser automation" in out
|
assert "Browser automation" in out
|
||||||
assert "active via Nous subscription" 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):
|
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
|
from hermes_cli import status as status_mod
|
||||||
|
|
||||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
_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))
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Nous Subscription Features" not in out
|
assert "Nous Tool Gateway" not in out
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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"}}
|
config = {"model": {"provider": "nous"}}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
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):
|
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"}}
|
config = {"model": {"provider": "nous"}}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
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):
|
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 = {
|
config = {
|
||||||
"model": {"provider": "nous"},
|
"model": {"provider": "nous"},
|
||||||
"platform_toolsets": {"cli": []},
|
"platform_toolsets": {"cli": []},
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,15 @@ def _restore_tool_and_agent_modules():
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _enable_managed_nous_tools(monkeypatch):
|
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():
|
def _install_fake_tools_package():
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,10 @@ def _restore_tool_and_agent_modules():
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _enable_managed_nous_tools(monkeypatch):
|
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():
|
def _install_fake_tools_package():
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,10 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain()
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
|
||||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
||||||
result = resolve_managed_tool_gateway(
|
result = resolve_managed_tool_gateway(
|
||||||
"firecrawl",
|
"firecrawl",
|
||||||
token_reader=lambda: "nous-token",
|
token_reader=lambda: "nous-token",
|
||||||
|
|
@ -39,11 +38,10 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
|
||||||
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
|
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
||||||
result = resolve_managed_tool_gateway(
|
result = resolve_managed_tool_gateway(
|
||||||
"browser-use",
|
"browser-use",
|
||||||
token_reader=lambda: "nous-token",
|
token_reader=lambda: "nous-token",
|
||||||
|
|
@ -57,11 +55,10 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
|
||||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
||||||
result = resolve_managed_tool_gateway(
|
result = resolve_managed_tool_gateway(
|
||||||
"firecrawl",
|
"firecrawl",
|
||||||
token_reader=lambda: None,
|
token_reader=lambda: None,
|
||||||
|
|
@ -70,8 +67,9 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag():
|
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
|
||||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
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(
|
result = resolve_managed_tool_gateway(
|
||||||
"firecrawl",
|
"firecrawl",
|
||||||
token_reader=lambda: "nous-token",
|
token_reader=lambda: "nous-token",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool")
|
||||||
def _clear_terminal_env(monkeypatch):
|
def _clear_terminal_env(monkeypatch):
|
||||||
"""Remove terminal env vars that could affect requirements checks."""
|
"""Remove terminal env vars that could affect requirements checks."""
|
||||||
keys = [
|
keys = [
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
|
||||||
"TERMINAL_ENV",
|
"TERMINAL_ENV",
|
||||||
"TERMINAL_MODAL_MODE",
|
"TERMINAL_MODAL_MODE",
|
||||||
"TERMINAL_SSH_HOST",
|
"TERMINAL_SSH_HOST",
|
||||||
|
|
@ -19,6 +18,11 @@ def _clear_terminal_env(monkeypatch):
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
monkeypatch.delenv(key, raising=False)
|
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):
|
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):
|
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
||||||
_clear_terminal_env(monkeypatch)
|
_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("TERMINAL_ENV", "modal")
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", 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):
|
def test_modal_backend_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path):
|
||||||
_clear_terminal_env(monkeypatch)
|
_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("TERMINAL_ENV", "modal")
|
||||||
monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
|
monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
|
||||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
|
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 ok is False
|
||||||
assert any(
|
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
|
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 ok is False
|
||||||
assert any(
|
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
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ class TestTerminalRequirements:
|
||||||
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
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):
|
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("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Unit tests for tools/tool_backend_helpers.py.
|
"""Unit tests for tools/tool_backend_helpers.py.
|
||||||
|
|
||||||
Tests cover:
|
Tests cover:
|
||||||
- managed_nous_tools_enabled() feature flag
|
- managed_nous_tools_enabled() subscription-based gate
|
||||||
- normalize_browser_cloud_provider() coercion
|
- normalize_browser_cloud_provider() coercion
|
||||||
- coerce_modal_mode() / normalize_modal_mode() validation
|
- coerce_modal_mode() / normalize_modal_mode() validation
|
||||||
- has_direct_modal_credentials() detection
|
- 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
|
# managed_nous_tools_enabled
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestManagedNousToolsEnabled:
|
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):
|
def test_disabled_when_not_logged_in(self, monkeypatch):
|
||||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.get_nous_auth_status",
|
||||||
|
lambda: {},
|
||||||
|
)
|
||||||
assert managed_nous_tools_enabled() is False
|
assert managed_nous_tools_enabled() is False
|
||||||
|
|
||||||
@pytest.mark.parametrize("val", ["1", "true", "True", "yes"])
|
def test_disabled_for_free_tier(self, monkeypatch):
|
||||||
def test_enabled_when_truthy(self, monkeypatch, val):
|
monkeypatch.setattr(
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
"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
|
assert managed_nous_tools_enabled() is True
|
||||||
|
|
||||||
@pytest.mark.parametrize("val", ["0", "false", "no", ""])
|
def test_returns_false_on_exception(self, monkeypatch):
|
||||||
def test_disabled_when_falsy(self, monkeypatch, val):
|
"""Should never crash — returns False on any exception."""
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.get_nous_auth_status",
|
||||||
|
_raise_import,
|
||||||
|
)
|
||||||
assert managed_nous_tools_enabled() is False
|
assert managed_nous_tools_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -171,10 +198,10 @@ class TestResolveModalBackendState:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False):
|
def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False):
|
||||||
"""Helper to call resolve_modal_backend_state with feature flag control."""
|
"""Helper to call resolve_modal_backend_state with feature flag control."""
|
||||||
if nous_enabled:
|
monkeypatch.setattr(
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
"tools.tool_backend_helpers.managed_nous_tools_enabled",
|
||||||
else:
|
lambda: nous_enabled,
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "")
|
)
|
||||||
return resolve_modal_backend_state(
|
return resolve_modal_backend_state(
|
||||||
mode, has_direct=has_direct, managed_ready=managed_ready
|
mode, has_direct=has_direct, managed_ready=managed_ready
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ class TestFirecrawlClientConfig:
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
tools.web_tools._firecrawl_client_config = None
|
tools.web_tools._firecrawl_client_config = None
|
||||||
for key in (
|
for key in (
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
|
@ -35,7 +34,15 @@ class TestFirecrawlClientConfig:
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
):
|
):
|
||||||
os.environ.pop(key, None)
|
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):
|
def teardown_method(self):
|
||||||
"""Reset client after each test."""
|
"""Reset client after each test."""
|
||||||
|
|
@ -43,7 +50,6 @@ class TestFirecrawlClientConfig:
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
tools.web_tools._firecrawl_client_config = None
|
tools.web_tools._firecrawl_client_config = None
|
||||||
for key in (
|
for key in (
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
|
@ -52,6 +58,8 @@ class TestFirecrawlClientConfig:
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
):
|
):
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
for p in self._managed_patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
# ── Configuration matrix ─────────────────────────────────────────
|
# ── Configuration matrix ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -298,7 +306,6 @@ class TestBackendSelection:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_ENV_KEYS = (
|
_ENV_KEYS = (
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
|
||||||
"EXA_API_KEY",
|
"EXA_API_KEY",
|
||||||
"PARALLEL_API_KEY",
|
"PARALLEL_API_KEY",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
|
|
@ -311,14 +318,20 @@ class TestBackendSelection:
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
|
||||||
for key in self._ENV_KEYS:
|
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):
|
def teardown_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
for p in self._managed_patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
# ── Config-based selection (web.backend in config.yaml) ───────────
|
# ── Config-based selection (web.backend in config.yaml) ───────────
|
||||||
|
|
||||||
|
|
@ -523,7 +536,6 @@ class TestCheckWebApiKey:
|
||||||
"""Test suite for check_web_api_key() unified availability check."""
|
"""Test suite for check_web_api_key() unified availability check."""
|
||||||
|
|
||||||
_ENV_KEYS = (
|
_ENV_KEYS = (
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
|
||||||
"EXA_API_KEY",
|
"EXA_API_KEY",
|
||||||
"PARALLEL_API_KEY",
|
"PARALLEL_API_KEY",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
|
|
@ -536,14 +548,20 @@ class TestCheckWebApiKey:
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
|
||||||
for key in self._ENV_KEYS:
|
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):
|
def teardown_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
for p in self._managed_patchers:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
def test_parallel_key_only(self):
|
def test_parallel_key_only(self):
|
||||||
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
|
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import requests
|
||||||
|
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
_pending_create_keys: Dict[str, str] = {}
|
_pending_create_keys: Dict[str, str] = {}
|
||||||
|
|
@ -75,7 +75,7 @@ class BrowserUseProvider(CloudBrowserProvider):
|
||||||
|
|
||||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||||
if api_key:
|
if api_key and not prefers_gateway("browser"):
|
||||||
return {
|
return {
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"base_url": _BASE_URL,
|
"base_url": _BASE_URL,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ from urllib.parse import urlencode
|
||||||
import fal_client
|
import fal_client
|
||||||
from tools.debug_helpers import DebugSession
|
from tools.debug_helpers import DebugSession
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -87,8 +87,9 @@ _managed_fal_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_managed_fal_gateway():
|
def _resolve_managed_fal_gateway():
|
||||||
"""Return managed fal-queue gateway config when direct FAL credentials are absent."""
|
"""Return managed fal-queue gateway config when the user prefers the gateway
|
||||||
if os.getenv("FAL_KEY"):
|
or direct FAL credentials are absent."""
|
||||||
|
if os.getenv("FAL_KEY") and not prefers_gateway("image_gen"):
|
||||||
return None
|
return None
|
||||||
return resolve_managed_tool_gateway("fal-queue")
|
return resolve_managed_tool_gateway("fal-queue")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -762,8 +762,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||||
if modal_state["managed_mode_blocked"]:
|
if modal_state["managed_mode_blocked"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Modal backend is configured for managed mode, but "
|
"Modal backend is configured for managed mode, but "
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
|
"a paid Nous subscription is required for the Tool Gateway and no direct "
|
||||||
"Modal credentials/config were found. Enable the feature flag or "
|
"Modal credentials/config were found. Log in with `hermes model` or "
|
||||||
"choose TERMINAL_MODAL_MODE=direct/auto."
|
"choose TERMINAL_MODAL_MODE=direct/auto."
|
||||||
)
|
)
|
||||||
if modal_state["mode"] == "managed":
|
if modal_state["mode"] == "managed":
|
||||||
|
|
@ -1577,8 +1577,8 @@ def check_terminal_requirements() -> bool:
|
||||||
if modal_state["managed_mode_blocked"]:
|
if modal_state["managed_mode_blocked"]:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
|
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
|
"a paid Nous subscription is required for the Tool Gateway and no direct "
|
||||||
"Modal credentials/config were found. Enable the feature flag "
|
"Modal credentials/config were found. Log in with `hermes model` "
|
||||||
"or choose TERMINAL_MODAL_MODE=direct/auto."
|
"or choose TERMINAL_MODAL_MODE=direct/auto."
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from utils import env_var_enabled
|
|
||||||
|
|
||||||
_DEFAULT_BROWSER_PROVIDER = "local"
|
_DEFAULT_BROWSER_PROVIDER = "local"
|
||||||
_DEFAULT_MODAL_MODE = "auto"
|
_DEFAULT_MODAL_MODE = "auto"
|
||||||
|
|
@ -14,8 +13,26 @@ _VALID_MODAL_MODES = {"auto", "direct", "managed"}
|
||||||
|
|
||||||
|
|
||||||
def managed_nous_tools_enabled() -> bool:
|
def managed_nous_tools_enabled() -> bool:
|
||||||
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
|
"""Return True when the user has an active paid Nous subscription.
|
||||||
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
|
|
||||||
|
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:
|
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", "")
|
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
|
||||||
or os.getenv("OPENAI_API_KEY", "")
|
or os.getenv("OPENAI_API_KEY", "")
|
||||||
).strip()
|
).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
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ from hermes_constants import display_hermes_home
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
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
|
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]:
|
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()
|
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
|
return direct_api_key, DEFAULT_OPENAI_BASE_URL
|
||||||
|
|
||||||
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ from tools.managed_tool_gateway import (
|
||||||
read_nous_access_token as _read_nous_access_token,
|
read_nous_access_token as _read_nous_access_token,
|
||||||
resolve_managed_tool_gateway,
|
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.url_safety import is_safe_url
|
||||||
from tools.website_policy import check_website_access
|
from tools.website_policy import check_website_access
|
||||||
|
|
||||||
|
|
@ -165,8 +165,8 @@ def _raise_web_backend_configuration_error() -> None:
|
||||||
)
|
)
|
||||||
if managed_nous_tools_enabled():
|
if managed_nous_tools_enabled():
|
||||||
message += (
|
message += (
|
||||||
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
|
" With your Nous subscription you can also use the Tool Gateway — "
|
||||||
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
|
"run `hermes tools` and select Nous Subscription as the web provider."
|
||||||
)
|
)
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
@ -176,8 +176,8 @@ def _firecrawl_backend_help_suffix() -> str:
|
||||||
if not managed_nous_tools_enabled():
|
if not managed_nous_tools_enabled():
|
||||||
return ""
|
return ""
|
||||||
return (
|
return (
|
||||||
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
|
", or use the Nous Tool Gateway via your subscription "
|
||||||
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
|
"(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -205,13 +205,14 @@ def _web_requires_env() -> list[str]:
|
||||||
def _get_firecrawl_client():
|
def _get_firecrawl_client():
|
||||||
"""Get or create Firecrawl client.
|
"""Get or create Firecrawl client.
|
||||||
|
|
||||||
Direct Firecrawl takes precedence when explicitly configured. Otherwise
|
When ``web.use_gateway`` is set in config, the Tool Gateway is preferred
|
||||||
Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers.
|
even if direct Firecrawl credentials are present. Otherwise direct
|
||||||
|
Firecrawl takes precedence when explicitly configured.
|
||||||
"""
|
"""
|
||||||
global _firecrawl_client, _firecrawl_client_config
|
global _firecrawl_client, _firecrawl_client_config
|
||||||
|
|
||||||
direct_config = _get_direct_firecrawl_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
|
kwargs, client_config = direct_config
|
||||||
else:
|
else:
|
||||||
managed_gateway = resolve_managed_tool_gateway(
|
managed_gateway = resolve_managed_tool_gateway(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue