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