Files
hermes-agent/hermes_cli/tools_config_providers.py
Robin Fernandes 749220ef00 feat(web): serve managed search through Perplexity
Use Perplexity for Nous-managed search, retaining Firecrawl for extract
and as a per-call search fallback. Explicit search overrides and direct
keys keep their own billing paths; fallback results are never cached.
When the managed route is selected but the Tool Gateway is unavailable
(unentitled account or no Nous token), search reports that selection
error instead of asking for a direct key the user never chose.

The managed search vendor is unannounced, so user-facing copy names the
capability rather than the vendor: status, portal and docs say "managed
web search", and the fallback annotation reads `managed_primary`. Direct-key
configuration docs are unchanged.

Routing, auth, payload, cache and entitlement regressions are covered
through real config loading and local HTTP.
2026-09-24 16:17:16 -04:00

1088 lines
53 KiB
Python

"""Provider rows, active-provider detection, model pickers and provider-selection persistence for `hermes tools`."""
from __future__ import annotations
import importlib
import logging
from functools import partial
from typing import Callable, Optional
from hermes_cli.cli_output import (
print_info as _print_info, print_success as _print_success, print_warning as _print_warning, prompt as _prompt,
)
from hermes_cli.colors import Colors, color
from hermes_cli.config import cfg_get, get_env_value, load_config, save_config, save_env_value
from hermes_cli.nous_account import format_nous_portal_entitlement_message
from hermes_cli.nous_subscription import MANAGED_FEATURE_COVERAGE_CATEGORY, NousSubscriptionFeatures
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, fal_key_is_configured
from utils import base_url_hostname, is_truthy_value
logger = logging.getLogger("hermes_cli.tools_config")
# tools_config-internal names (TOOL_CATEGORIES, _cfg_section, _prompt_choice, ...) are imported lazily
# inside the functions that need them: tools_config re-imports this module, and tests patch those names there.
def _plugin_registry(module: str):
"""Import a plugin registry module after plugin discovery; ``None`` on any failure."""
try:
registry = importlib.import_module(module)
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
return registry
except Exception:
return None
def _plugin_provider_rows(
registry_module: str, marker_keys: tuple[str, ...], *, require_name: bool = True, skip_builtin: bool = False,
flatten_variants: bool = False) -> list[dict]:
"""Picker-row dicts (TOOL_CATEGORIES-shaped) for a plugin registry's providers.
``marker_keys`` are all set to the registry name so downstream config/model-picker code routes
through the registry. ``skip_builtin`` drops names shadowing ``_BUILTIN_NAMES``;
``flatten_variants`` expands a schema's tier ``variants`` into separate rows sharing one backend
name, distinguished by ``web_tier``."""
registry = _plugin_registry(registry_module)
if registry is None:
return []
try:
providers = registry.list_providers()
builtin = registry._BUILTIN_NAMES if skip_builtin else frozenset()
except Exception:
return []
rows: list[dict] = []
for provider in providers:
if require_name:
name = getattr(provider, "name", None)
if not name or (skip_builtin and name.lower().strip() in builtin):
continue
try:
schema = provider.get_setup_schema()
except Exception:
continue
if not isinstance(schema, dict):
continue
if not require_name:
name = provider.name
entries = [schema]
if flatten_variants:
entries += [v for v in (schema.get("variants") or []) if isinstance(v, dict)]
for entry in entries:
row = {"name": entry.get("name", provider.display_name), "badge": entry.get("badge", ""),
"tag": entry.get("tag", ""), "env_vars": entry.get("env_vars", [])}
row.update({key: name for key in marker_keys})
if flatten_variants and entry.get("web_tier"):
row["web_tier"] = entry["web_tier"]
if entry.get("post_setup"):
row["post_setup"] = entry["post_setup"]
rows.append(row)
return rows
# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). ``*_plugin_name`` markers route
# config writes + model pickers through the registry; ``browser_provider`` is the legacy ``browser.cloud_provider``
# key; TTS rows write ``tts.provider: <name>``.
_PLUGIN_PROVIDER_ROW_SPECS = {
"image_gen": ("agent.image_gen_registry", ("image_gen_plugin_name",), {"require_name": False}),
"video_gen": ("agent.video_gen_registry", ("video_gen_plugin_name",), {"require_name": False}),
"web": ("agent.web_search_registry", ("web_backend", "web_search_plugin_name"), {"flatten_variants": True}),
"browser": ("agent.browser_registry", ("browser_provider", "browser_plugin_name"), {}),
"tts": ("agent.tts_registry", ("tts_provider", "tts_plugin_name"), {"skip_builtin": True})}
def _plugin_rows_for(category: str) -> list[dict]:
"""Picker rows for the plugin-registered providers of one ``_PLUGIN_PROVIDER_ROW_SPECS`` category."""
module, markers, kwargs = _PLUGIN_PROVIDER_ROW_SPECS[category]
return _plugin_provider_rows(module, markers, **kwargs)
_plugin_image_gen_providers, _plugin_video_gen_providers, _plugin_web_search_providers, \
_plugin_browser_providers, _plugin_tts_providers = (
partial(_plugin_rows_for, cat) for cat in ("image_gen", "video_gen", "web", "browser", "tts"))
def web_provider_capabilities(backend: str) -> list:
"""Capabilities (``search`` / ``extract``) a web backend supports, per the registry provider instance.
Lets the Capabilities GUI offer ``web.search_backend`` / ``web.extract_backend`` only where it makes
sense (ddgs and brave-free are search-only). Unknown backend or registry failure -> both."""
try:
from agent.web_search_registry import get_provider
provider = get_provider(backend)
if provider is not None:
return [cap for cap, supported in (("search", provider.supports_search()),
("extract", provider.supports_extract())) if supported]
except Exception:
pass
return ["search", "extract"]
# TOOL_CATEGORIES[<key>]["name"] -> builder of plugin-registered picker rows.
_PLUGIN_ROW_BUILDERS = {
"Image Generation": _plugin_image_gen_providers,
"Video Generation": _plugin_video_gen_providers,
"Web Search & Extract": _plugin_web_search_providers,
"Browser Automation": _plugin_browser_providers,
"Text-to-Speech": _plugin_tts_providers}
def _visible_providers(
cat: dict, config: dict, *, force_fresh: bool = False, features: Optional[NousSubscriptionFeatures] = None,
) -> list[dict]:
"""Provider entries visible for the current auth/config state.
Nous-managed rows (``managed_nous_feature``) are always shown, even logged-out/unentitled, to
advertise the capability."""
from hermes_cli.tools_config import get_nous_subscription_features
if features is None:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
acct = features.account_info
# Pool-only users (free tool pool, no paid access) get image gen but NOT video gen — the pool doesn't
# fund `fal-video`, so hide the managed video row rather than advertise a denial.
pool_only = bool(acct and acct.logged_in and acct.paid_service_access is not True and acct.tool_gateway_entitled)
visible = []
for provider in cat.get("providers", []):
managed = provider.get("managed_nous_feature")
# Managed rows stay visible regardless of auth (selecting one drives an inline Portal login); a
# `requires_nous_auth` row without a managed feature hides until logged in.
if provider.get("requires_nous_auth") and not managed and not features.nous_auth_present:
continue
if pool_only and managed == "video_gen" and not (acct and acct.tool_gateway_entitled_for("fal-video")):
continue
visible.append(provider)
# Plugin-registered rows render BELOW the hardcoded rows (for web/browser they are the only real provider rows).
builder = _PLUGIN_ROW_BUILDERS.get(cat.get("name"))
if builder is not None:
visible.extend(builder())
return visible
def provider_readiness_status(provider: dict, config: dict, *, features=None, is_active: Optional[bool] = None) -> str:
"""Honest readiness state for a provider picker row.
``features`` avoids re-fetching portal state per row. ``is_active`` is the completed-setup fallback
for post_setup hooks with no registered installed-check (selecting a row runs its hook)."""
from hermes_cli.tools_config import _POST_SETUP_READY, _provider_env_ready, get_nous_subscription_features
from hermes_cli.tools_config_post_setup import _POST_SETUP_AUTH_READY
if provider.get("env_vars", []):
return "ready" if _provider_env_ready(provider) else "needs_keys"
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth") or managed_feature:
if features is None:
features = get_nous_subscription_features(config)
if not features.nous_auth_present:
return "needs_auth"
if managed_feature:
# Same per-category entitlement gate the CLI applies at selection time.
acct = features.account_info
category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
entitled = bool(acct and acct.logged_in and (
acct.tool_gateway_entitled_for(category) if category else acct.tool_gateway_entitled))
if not entitled:
return "needs_auth"
# Signed in and entitled — fall through: a managed row may still carry a local install hook.
post_setup = provider.get("post_setup")
if post_setup:
auth_predicate = _POST_SETUP_AUTH_READY.get(post_setup)
if auth_predicate is not None:
return "ready" if auth_predicate() else "needs_auth"
predicate = _POST_SETUP_READY.get(post_setup)
if predicate is not None:
try:
return "ready" if predicate() else "needs_setup"
except Exception:
return "ready" # flaky detection must not manufacture a warning state
# No installed-check registered → the active-provider signal means "setup completed".
if is_active is None:
is_active = _is_provider_active(provider, config)
return "ready" if is_active else "needs_setup"
return "ready"
def _toolset_needs_configuration_prompt(ts_key: str, config: dict, *, force_fresh: bool = False) -> bool:
"""Return True when enabling this toolset should open provider setup."""
from hermes_cli.tools_config import TOOL_CATEGORIES, _post_setup_already_installed, _toolset_has_keys
cat = TOOL_CATEGORIES.get(ts_key)
if not cat:
return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh)
# An unsatisfied post_setup install-state check (e.g. cua-driver not on PATH yet) forces the
# configuration flow so `_configure_provider` runs the hook and the install actually happens.
for provider in _visible_providers(cat, config, force_fresh=force_fresh):
post_setup = provider.get("post_setup")
if post_setup and not _post_setup_already_installed(post_setup):
return True
# Categories whose "configured" signal is a selected provider key.
selection_key = {"tts": "provider", "web": "backend", "browser": "cloud_provider"}.get(ts_key)
if selection_key:
section = config.get(ts_key, {})
if not isinstance(section, dict):
return True
if selection_key in section:
return False
# Browser's "Browser Use" row writes browser.backend and leaves cloud_provider unset. Presence is no
# test of a choice here: browser.backend exists on every install after the defaults merge ("" = unset).
return not (ts_key == "browser" and _browser_backend(config))
if ts_key == "image_gen": # in-tree FAL backend OR any available plugin image gen provider satisfies
return not fal_key_is_configured() and not _any_plugin_provider_available("agent.image_gen_registry")
if ts_key == "video_gen": # no in-tree fallback — every video backend is a plugin
return not _any_plugin_provider_available("agent.video_gen_registry")
return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh)
def _any_plugin_provider_available(registry_module: str) -> bool:
"""True when any provider in the plugin registry reports ``is_available()``."""
registry = _plugin_registry(registry_module)
try:
for provider in registry.list_providers():
try:
if provider.is_available():
return True
except Exception:
continue
except Exception:
pass
return False
def _configure_tool_category(ts_key: str, cat: dict, config: dict, *, force_fresh: bool = True, reconfigure: bool = False):
"""Provider selection for a tool category, then API-key setup for the chosen row.
``reconfigure`` ("Reconfigure an existing tool"): no setup note / skip row / Nous marker, and the
chosen provider goes through the key-update prompts instead of the new-enable prompts."""
from hermes_cli.tools_config import _prompt_choice, _provider_env_ready, get_nous_subscription_features
name = cat["name"]
providers = _visible_providers(cat, config, force_fresh=force_fresh)
single = len(providers) == 1
title = "Choose a provider" if reconfigure else cat.get("setup_title", "Choose a provider")
print()
heading = f"({providers[0]['name']})" if single else f"- {title}"
print(color(f" --- {cat.get('icon', '')} {name} {heading} ---", Colors.CYAN))
if single and not reconfigure and providers[0].get("tag"):
_print_info(f" {providers[0]['tag']}")
if not reconfigure and cat.get("setup_note"):
_print_info(f" {cat['setup_note']}")
if single:
_configure_provider(providers[0], config, force_fresh=force_fresh, reconfigure=reconfigure)
return
print()
# Logged-in Nous users get a marker on rows included in their subscription (cost-extra vs. included).
_nous_logged_in = False
if not reconfigure:
try:
_nous_logged_in = bool(get_nous_subscription_features(config, force_fresh=force_fresh).nous_auth_present)
except Exception:
pass
provider_choices = [] # plain text labels only (no ANSI codes in menu items)
for p in providers:
badge = f" [{p['badge']}]" if p.get("badge") else ""
tag = f" — {p['tag']}" if p.get("tag") else ""
configured = ""
if _provider_env_ready(p):
if _is_provider_active(p, config, force_fresh=force_fresh):
configured = " [active]"
elif p.get("env_vars", []):
configured = " [configured]"
# Subscribers get the "included" star; everyone else a hint that selecting triggers a Portal login.
sub_marker = ""
if not reconfigure and p.get("managed_nous_feature"):
sub_marker = " ★ Included with your Nous subscription" if _nous_logged_in else " ★ via Nous Portal (login on select)"
provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}")
if not reconfigure:
provider_choices.append("Skip — keep defaults / configure later")
default_idx = _detect_active_provider_index(providers, config, force_fresh=force_fresh)
question = " Select provider:" if reconfigure else f" {title}:"
provider_idx = _prompt_choice(question, provider_choices, default_idx)
if provider_idx >= len(providers):
_print_info(f" Skipped {name}")
return
_configure_provider(providers[provider_idx], config, force_fresh=force_fresh, reconfigure=reconfigure)
def _web_tier_matches(provider: dict, config: dict) -> bool:
"""True when a web picker row's tier matches the configured tier (``web.provider_tier.<backend>``).
Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` and differ only in ``web_tier``.
No ``web_tier`` on the row → matches; configured tier set → must equal the row's tier; unset →
"auto": paid when the row's key is present, free otherwise (highlight the row the runtime would
actually use)."""
row_tier = provider.get("web_tier")
if not row_tier:
return True
web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
tiers = web_cfg.get("provider_tier") if isinstance(web_cfg.get("provider_tier"), dict) else {}
configured = str(tiers.get(provider["web_backend"], "") or "").lower().strip()
if configured in ("free", "paid"):
return configured == row_tier
# Auto: mirror plugins.web.keyless_mcp.use_keyless — key present → paid.
try:
from agent.web_search_provider import get_provider_env
key_var = {"exa": "EXA_API_KEY", "parallel": "PARALLEL_API_KEY"}.get(provider["web_backend"])
has_key = bool(get_provider_env(key_var)) if key_var else False
except Exception:
has_key = False
return row_tier == ("paid" if has_key else "free")
# Managed-row marker -> (config section, key) the pick writes, in check order.
_MANAGED_SELECTION_KEYS: tuple[tuple[str, str, str], ...] = (
("tts_provider", "tts", "provider"), ("stt_provider", "stt", "provider"),
("browser_provider", "browser", "cloud_provider"), ("web_backend", "web", "backend"))
def _has_marker(provider: dict, marker: str) -> bool:
"""``browser_provider`` is a membership test (a local row carries ``browser_provider: ""``); every other
marker is a truthiness test."""
return marker in provider if marker == "browser_provider" else bool(provider.get(marker))
def _managed_provider_active(provider: dict, config: dict, managed_feature: str, force_fresh: bool) -> bool:
"""Active check for a Nous-managed row: the feature must be managed AND the category's selected
provider must be the row's vendor or ``nous``."""
from hermes_cli.tools_config import get_nous_subscription_features
feature = get_nous_subscription_features(config, force_fresh=force_fresh).features.get(managed_feature)
if feature is None:
return False
if managed_feature in ("image_gen", "video_gen"):
gen_cfg = config.get(managed_feature, {})
if isinstance(gen_cfg, dict):
configured_provider = gen_cfg.get("provider")
if configured_provider not in {None, "", "fal", NOUS_MANAGED_PROVIDER}:
return False
if (
configured_provider != NOUS_MANAGED_PROVIDER
and gen_cfg.get("use_gateway") is not None
and not is_truthy_value(gen_cfg.get("use_gateway"), default=False)):
return False
return feature.managed_by_nous
# Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so the browser
# provider row stays active alongside the Browser Use row.
for marker, section, key in _MANAGED_SELECTION_KEYS:
if _has_marker(provider, marker):
current = cfg_get(config, section, key)
selected = current in {provider[marker], NOUS_MANAGED_PROVIDER}
if marker == "web_backend":
selected = selected and _web_tier_matches(provider, config)
return feature.managed_by_nous and selected
return feature.managed_by_nous
def _browser_use_default_active(config: dict) -> bool:
"""``browser.backend`` unset: Browser Use mode is the default, so the row is active whenever the
effective mode resolves on (legacy direct-API cloud config, or CLI runnable and no Camofox)."""
browser_cfg = config.get("browser") if isinstance(config, dict) else None
try:
from tools.browser_use_cli import _find_cli, is_legacy_browser_use_cloud_config
if is_legacy_browser_use_cloud_config(browser_cfg or {}):
return True
try:
from tools.browser_camofox import is_camofox_mode
if is_camofox_mode():
return False
except Exception:
pass
return _find_cli() is not None
except Exception:
return False
def _browser_provider_active(provider: dict, config: dict) -> bool:
# Browser Use mode composes with the provider (driver over its CDP endpoint) — don't deactivate the row.
if provider["browser_provider"] != cfg_get(config, "browser", "cloud_provider"):
return False
# Two local rows differ only by engine ("Local Browser" vs "Lightpanda"): config.yaml is the picker's
# source of truth here, the AGENT_BROWSER_ENGINE env var is not consulted.
if provider.get("browser_engine"):
engine = str(cfg_get(config, "browser", "engine") or "auto").strip().lower()
return engine == provider["browser_engine"]
return True
def _browser_backend(config: dict) -> str:
"""``browser.backend`` as a string; ``""`` when unset or empty (YAML 1.1 parses an unquoted ``off`` as False)."""
backend = cfg_get(config, "browser", "backend")
return "off" if backend is False else (backend or "")
def _browser_backend_active(provider: dict, config: dict) -> bool:
backend = _browser_backend(config)
if backend == provider["browser_backend"]:
return True
if backend:
return False # explicit other choice ("off", …) wins
if provider["browser_backend"] != "browser-use":
return False
return _browser_use_default_active(config)
def _imagegen_backend_active(provider: dict, config: dict) -> bool:
image_cfg = config.get("image_gen", {})
return (
isinstance(image_cfg, dict)
and provider["imagegen_backend"] == "fal"
and image_cfg.get("provider") in {None, "", "fal"}
and not is_truthy_value(image_cfg.get("use_gateway"), default=False))
# Non-managed active checks, evaluated in order; the first marker the row carries decides (see ``_has_marker``).
# Default stt.provider is "local" — an unset key means Local Whisper.
_ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = (
("tts_provider", lambda p, c: cfg_get(c, "tts", "provider") == p["tts_provider"]),
("stt_provider", lambda p, c: (cfg_get(c, "stt", "provider") or "local") == p["stt_provider"]),
("browser_provider", _browser_provider_active),
("browser_backend", _browser_backend_active),
("web_backend", lambda p, c: cfg_get(c, "web", "backend") == p["web_backend"] and _web_tier_matches(p, c)),
("computer_use_backend", lambda p, c: cfg_get(c, "computer_use", "backend") == p["computer_use_backend"]),
("imagegen_backend", _imagegen_backend_active))
def _is_provider_active(provider: dict, config: dict, *, force_fresh: bool = False) -> bool:
"""Check if a provider entry matches the currently active config."""
managed_feature = provider.get("managed_nous_feature")
# Managed entries fall through to the managed branch, which also checks use_gateway — otherwise a
# managed FAL pick and a direct-key FAL pick would both report active.
for section in ("image_gen", "video_gen"):
plugin_name = provider.get(f"{section}_plugin_name")
if plugin_name and not managed_feature:
gen_cfg = config.get(section, {})
if not (isinstance(gen_cfg, dict) and gen_cfg.get("provider") == plugin_name):
return False
# A direct-key image gen entry is only active when the managed route is OFF.
return section == "video_gen" or not is_truthy_value(gen_cfg.get("use_gateway"), default=False)
if managed_feature:
return _managed_provider_active(provider, config, managed_feature, force_fresh)
for marker, check in _ACTIVE_CHECKS:
if _has_marker(provider, marker):
return check(provider, config)
return False
def _detect_active_provider_index(providers: list, config: dict, *, force_fresh: bool = False) -> int:
"""Return the index of the currently active provider, or 0."""
from hermes_cli.tools_config import _provider_env_ready
for i, p in enumerate(providers):
if _is_provider_active(p, config, force_fresh=force_fresh):
return i
if p.get("env_vars", []) and _provider_env_ready(p): # fallback: env vars present → likely configured
return i
return 0
def _fal_model_catalog(config: dict):
"""Lazy-load the FAL model catalog."""
from tools.image_generation_catalog import FAL_MODELS, DEFAULT_MODEL
return FAL_MODELS, DEFAULT_MODEL
def _managed_image_catalog(config: dict):
"""The managed row's union catalog (FAL + Krea + Portal), minus the gateways this account cannot use.
A free-pool account is funded for FAL only, so its picker never offers a Krea or Portal model it
would be denied at generation time; a logged-out or paid account sees everything."""
from hermes_cli.tools_config import get_nous_subscription_features
from tools.image_generation_managed import managed_image_catalog
acct = get_nous_subscription_features(config).account_info
pool_only = bool(acct and acct.logged_in and acct.paid_service_access is not True)
return managed_image_catalog(
include_krea=not pool_only or acct.tool_gateway_entitled_for("krea"), include_portal=not pool_only)
# Per-backend model catalog (config_key = top-level config.yaml section, catalog_fn(config) -> ({model_id:
# metadata}, default_model)); a TOOL_CATEGORIES row tagged `imagegen_backend: "<name>"` selects the catalog at
# picker time. "nous" is the single managed row: one catalog spanning the FAL, Krea and Portal gateways.
IMAGEGEN_BACKENDS = {
"fal": {"display": "FAL.ai", "config_key": "image_gen", "catalog_fn": _fal_model_catalog},
"nous": {"display": "Nous Subscription", "config_key": "image_gen", "catalog_fn": _managed_image_catalog}}
def _plugin_model_catalog(registry_module: str, plugin_name: str):
"""``(catalog_dict, default_model_id)`` for a plugin provider; ``catalog_dict`` is shaped like the legacy
``FAL_MODELS`` table so the picker path is shared. ``({}, None)`` if unregistered or no models."""
registry = _plugin_registry(registry_module)
try: # a missing registry / unknown provider surfaces as AttributeError here — same ({}, None) outcome
provider = registry.get_provider(plugin_name)
models = provider.list_models() or []
default = provider.default_model()
except Exception:
return {}, None
return {m["id"]: m for m in models if isinstance(m, dict) and "id" in m}, default
_plugin_image_gen_catalog = partial(_plugin_model_catalog, "agent.image_gen_registry")
_plugin_video_gen_catalog = partial(_plugin_model_catalog, "agent.video_gen_registry")
def _pick_model_from_catalog(
catalog: dict, default_model, cfg_key: str, display: str, config: dict, *, row_indent: str = "",
) -> None:
"""Column-aligned model picker shared by the FAL, plugin image gen and video gen flows.
Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the cursor
lands on it; a saved model belonging to another provider (shared config key) or a drifted catalog
default never indexes the catalog. Safe when stdin is not a TTY — curses_radiolist keeps the current
selection."""
from hermes_cli.tools_config import _cfg_section, _prompt_choice
if not catalog:
return
cur_cfg = _cfg_section(config, cfg_key)
current_model = cur_cfg.get("model") or default_model
if current_model not in catalog:
current_model = default_model if default_model in catalog else next(iter(catalog))
model_ids = list(catalog.keys())
ordered = [current_model] + [m for m in model_ids if m != current_model]
widths = {
"model": max(len(m) for m in model_ids),
"speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6),
"strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0)}
print()
header = (f" {'Model':<{widths['model']}} {'Speed':<{widths['speed']}} "
f"{'Strengths':<{widths['strengths']}} Price")
print(color(header, Colors.CYAN))
rows = []
for mid in ordered:
meta = catalog[mid]
row = (f"{row_indent}{mid:<{widths['model']}} {meta.get('speed', ''):<{widths['speed']}} "
f"{meta.get('strengths', ''):<{widths['strengths']}} {meta.get('price', '')}")
if mid == current_model:
row += " ← currently in use"
rows.append(row)
idx = _prompt_choice(f" Choose {display} model:", rows, default=0)
chosen = ordered[idx]
cur_cfg["model"] = chosen
_print_success(f" Model set to: {chosen}")
def _configure_imagegen_model(backend_name: str, config: dict) -> None:
"""Prompt for a model of an in-tree imagegen backend (``IMAGEGEN_BACKENDS``)."""
backend = IMAGEGEN_BACKENDS.get(backend_name)
if not backend:
return
catalog, default_model = backend["catalog_fn"](config)
_pick_model_from_catalog(catalog, default_model, backend["config_key"], backend["display"], config)
def _configure_gen_model_for_plugin(section: str, plugin_name: str, config: dict) -> None:
"""Prompt for a model from a plugin-registered image/video gen catalog (video rows keep their historical
two-space indent)."""
catalog_fn = _plugin_image_gen_catalog if section == "image_gen" else _plugin_video_gen_catalog
catalog, default_model = catalog_fn(plugin_name)
_pick_model_from_catalog(catalog, default_model, section, plugin_name, config,
row_indent="" if section == "image_gen" else " ")
_configure_imagegen_model_for_plugin = partial(_configure_gen_model_for_plugin, "image_gen")
_configure_videogen_model_for_plugin = partial(_configure_gen_model_for_plugin, "video_gen")
def _configure_xai_imagine_storage(section_name: str, config: dict) -> None:
"""Prompt for xAI Imagine stored public URL behavior."""
from hermes_cli.tools_config import _cfg_section, _prompt_choice
storage_cfg = _cfg_section(_cfg_section(_cfg_section(config, section_name), "xai"), "storage")
_print_warning(
" xAI Imagine can store generated media and create reusable public URLs. "
"xAI may bill for stored files and public URL hosting.")
choices = ["Enable public URLs without automatic expiry (recommended)", "Disable stored public URLs",
"Enable public URLs for 2 days"]
idx = _prompt_choice(" Stored public URLs:", choices, default=0)
if idx == 1:
storage_cfg["enabled"] = False
_print_success(" xAI stored public URLs disabled")
return
storage_cfg["enabled"] = True
storage_cfg["public_url"] = True
storage_cfg["expires_after"] = 2 * 24 * 60 * 60 if idx == 2 else None
_print_success(" xAI stored public URLs enabled for 2 days" if idx == 2
else " xAI stored public URLs enabled without automatic expiry")
def _select_into(config: dict, section: str, key: str, vendor, managed) -> dict:
"""Write ``config[section][key] = vendor`` (``nous`` for a managed pick) and drop any legacy ``use_gateway``
key so the old read-time shim cannot override the new choice. Returns the section dict."""
from hermes_cli.tools_config import _cfg_section
cfg = _cfg_section(config, section)
cfg[key] = NOUS_MANAGED_PROVIDER if managed else vendor
cfg.pop("use_gateway", None)
return cfg
def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed image/video gen provider selection (``nous`` for a Nous-managed pick, else the
plugin name) and run its model picker."""
cfg = _select_into(config, section, "provider", plugin_name, use_gateway)
_print_success(f" {section}.provider set to: {cfg['provider']}")
_configure_gen_model_for_plugin(section, plugin_name, config)
if plugin_name == "xai":
_configure_xai_imagine_storage(section, config)
_select_plugin_image_gen_provider = partial(_select_plugin_gen_provider, "image_gen")
_select_plugin_video_gen_provider = partial(_select_plugin_gen_provider, "video_gen")
# Per-provider STT model catalogs for the picker; keys are ``stt.<provider>`` sections, first entry is the
# default. Kept in sync with the dashboard selects (web_server _CONFIG_FIELD_META) and the desktop settings
# enums (apps/desktop/src/app/settings/constants.ts).
STT_MODEL_CATALOG = {
"local": ["base", "tiny", "small", "medium", "large-v3"],
"groq": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"],
"openai": ["whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"],
"elevenlabs": ["scribe_v2", "scribe_v1"]}
# ElevenLabs historically uses ``model_id`` instead of ``model``.
_STT_MODEL_CONFIG_KEY = {"elevenlabs": "model_id"}
def _configure_stt_model(stt_provider: str, config: dict) -> None:
"""Prompt for the STT model after a provider pick (when a catalog exists)."""
from hermes_cli.tools_config import _cfg_section, _prompt_choice
catalog = STT_MODEL_CATALOG.get(stt_provider)
if not catalog:
return
prov_cfg = _cfg_section(_cfg_section(config, "stt"), stt_provider)
model_key = _STT_MODEL_CONFIG_KEY.get(stt_provider, "model")
current = str(prov_cfg.get(model_key) or "").strip()
ordered = list(catalog)
chosen = ordered[_prompt_choice(" Select STT model:", ordered, ordered.index(current) if current in ordered else 0)]
prov_cfg[model_key] = chosen
_print_success(f" STT model set to: {chosen}")
# Provider-row marker key -> config section it selects into.
_PROVIDER_MARKER_SECTIONS = {
"tts_provider": "tts", "stt_provider": "stt", "browser_provider": "browser", "web_backend": "web",
"image_gen_plugin_name": "image_gen", "imagegen_backend": "image_gen", "video_gen_plugin_name": "video_gen",
}
def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None:
"""Persist the provider/backend config keys for a selected provider.
Pure, non-interactive core of :func:`_configure_provider` (no env prompts, post-setup hooks, Nous
auth gating or model pickers) shared by the CLI and the GUI ``PUT .../provider`` endpoint. Each pick
writes exactly ONE provider string per category (``nous`` for managed rows) and removes any legacy
``use_gateway`` key so the read-time shim cannot override the new choice."""
from hermes_cli.tools_config import TOOL_CATEGORIES
for marker, section_key in (("tts_provider", "tts"), ("stt_provider", "stt")):
if provider.get(marker):
_select_into(config, section_key, "provider", provider[marker], managed_feature)
if "browser_provider" in provider:
bp = provider["browser_provider"]
if bp or managed_feature:
# Browser Use mode (browser.backend) composes with the provider — keep the driver choice intact.
_select_into(config, "browser", "cloud_provider", bp, managed_feature)
else:
config.setdefault("browser", {}).pop("use_gateway", None)
if provider.get("browser_backend"):
config.setdefault("browser", {})["backend"] = provider["browser_backend"]
# Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda); composes with browser.backend.
if provider.get("browser_engine"):
config.setdefault("browser", {})["engine"] = provider["browser_engine"]
if provider.get("web_backend"):
web_cfg = _select_into(config, "web", "backend", provider["web_backend"], managed_feature)
tier = provider.get("web_tier")
tiers = web_cfg.setdefault("provider_tier", {}) if tier else web_cfg.get("provider_tier")
if isinstance(tiers, dict):
if tier:
tiers[provider["web_backend"]] = tier
else:
tiers.pop(provider["web_backend"], None)
if provider.get("computer_use_backend"):
config.setdefault("computer_use", {})["backend"] = provider["computer_use_backend"]
if managed_feature and managed_feature not in {"web", "tts", "stt", "browser"}:
# Managed rows without a marker above (image_gen/video_gen "Nous Subscription" rows carry only
# managed_nous_feature) still persist the "nous" selection.
section = config.setdefault(managed_feature, {})
if isinstance(section, dict):
section["provider"] = NOUS_MANAGED_PROVIDER
section.pop("use_gateway", None)
elif not managed_feature:
# Non-gateway pick — clear any stale legacy use_gateway key on the category. Resolve the category from
# the row's own markers first (plugin-injected rows are NOT in TOOL_CATEGORIES' hardcoded lists), then
# fall back to the category-membership walk.
sections = [section_key for marker, section_key in _PROVIDER_MARKER_SECTIONS.items() if marker in provider]
if not sections:
sections = [cat_key for cat_key, cat in TOOL_CATEGORIES.items() if provider in cat.get("providers", [])][:1]
for section_key in sections:
if isinstance(config.get(section_key), dict):
config[section_key].pop("use_gateway", None)
def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None:
"""Non-interactively persist a provider selection for a toolset (config keys only — API keys, post-setup
hooks, auth gating and model pickers are separate GUI endpoints). ``provider_name`` is resolved among
:func:`_visible_providers` rows; raises ``KeyError`` for an unknown toolset or provider."""
from hermes_cli.tools_config import TOOL_CATEGORIES
cat = TOOL_CATEGORIES.get(ts_key)
if cat is None:
raise KeyError(f"Toolset has no configurable category: {ts_key}")
providers = _visible_providers(cat, config, force_fresh=True)
provider = next((p for p in providers if p.get("name") == provider_name), None)
if provider is None:
raise KeyError(f"Unknown provider {provider_name!r} for toolset {ts_key!r}")
managed_feature = provider.get("managed_nous_feature")
_write_provider_config(provider, config, managed_feature=managed_feature)
# Plugin image/video gen backends record the provider name in their own section (model choice is a separate
# GUI flow); managed picks store "nous". The in-tree FAL BYOK row always persists an explicit
# ``image_gen.provider: fal`` so a deliberate pick is distinguishable from a never-configured install.
selections = [
("image_gen", provider.get("image_gen_plugin_name")),
("video_gen", provider.get("video_gen_plugin_name")),
("image_gen", "fal" if provider.get("imagegen_backend") and not managed_feature else None)]
for section_key, vendor in selections:
if vendor:
_select_into(config, section_key, "provider", vendor, managed_feature)
def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_fresh: bool) -> bool:
"""Return False (after printing why) when a Nous-gated row cannot be selected.
Managed Tool Gateway rows are always listed but only *activate* with paid Nous Portal access —
selecting one runs an inline Portal login (auth + entitlement only, no inference-provider switch).
Pure pre-auth UX rows (``requires_nous_auth`` without a managed feature) keep the older logged-in +
entitled gate."""
from hermes_cli.tools_config import get_nous_subscription_features
if managed_feature:
from hermes_cli.nous_subscription import ensure_nous_portal_access
if not ensure_nous_portal_access(
capability=f"{provider.get('name', 'the Nous Tool Gateway')}",
coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)):
_print_warning(" Not enabled — Nous Portal access is required for this backend.")
return False
return True
if provider.get("requires_nous_auth"):
features = get_nous_subscription_features(config, force_fresh=force_fresh)
entitled = bool(features.account_info and features.account_info.paid_service_access is True)
if not features.nous_auth_present or not entitled:
message = format_nous_portal_entitlement_message(
features.account_info, capability=f"{provider.get('name', 'Nous Subscription')}")
_print_warning(f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}")
return False
return True
def _finish_provider_selection(provider: dict, config: dict, managed_feature) -> None:
"""Model pickers that follow a provider pick: plugin image/video gen, in-tree FAL, STT."""
for section in ("image_gen", "video_gen"):
plugin_name = provider.get(f"{section}_plugin_name")
if plugin_name:
_select_plugin_gen_provider(section, plugin_name, config, use_gateway=bool(managed_feature))
return
backend = provider.get("imagegen_backend")
if backend:
_configure_imagegen_model(backend, config)
# "nous" for the managed row (the picked model id chooses the FAL / Krea / Portal gateway at run time),
# "fal" for BYOK, drop legacy use_gateway — never clobber a managed pick back onto direct keys.
_select_into(config, "image_gen", "provider", "fal", managed_feature)
# STT rows prompt for a model after the pick (skipped for managed rows — the gateway pins it).
if provider.get("stt_provider") and not managed_feature:
_configure_stt_model(provider["stt_provider"], config)
def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: bool) -> None:
"""Status lines announcing which backend/provider keys a pick writes."""
if reconfigure and provider.get("tts_provider"):
_print_success(f" TTS provider set to: {provider['tts_provider']}")
if provider.get("stt_provider"):
_print_success(f" STT provider set to: {provider['stt_provider']}")
if "browser_provider" in provider:
bp = provider["browser_provider"]
if reconfigure and managed_feature:
_print_success(f" Browser cloud provider set to: {bp or 'nous'}")
elif bp == "local":
_print_success(" Browser set to local mode")
elif bp:
_print_success(f" Browser cloud provider set to: {bp}")
if provider.get("browser_backend"):
_print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)")
if provider.get("browser_engine") and provider["browser_engine"] != "auto":
_print_success(f" Browser engine set to: {provider['browser_engine']}")
if provider.get("web_backend"):
tier = f" ({provider['web_tier']} tier)" if reconfigure and provider.get("web_tier") else ""
backend = NOUS_MANAGED_PROVIDER if managed_feature else provider["web_backend"]
_print_success(f" Web backend set to: {backend}{tier}")
if reconfigure and provider.get("computer_use_backend"):
_print_success(f" Computer Use backend set to: {provider['computer_use_backend']}")
def _show_portal_hint(provider: dict, config: dict, managed_feature, force_fresh: bool) -> bool:
"""True when a BYOK row shares its category with a Nous-managed sibling and the user is not authed to
Nous — a single dim hint tells them the key is avoidable via a Portal subscription."""
from hermes_cli.tools_config import TOOL_CATEGORIES, get_nous_subscription_features
if managed_feature or provider.get("requires_nous_auth"):
return False
try:
for _cat in TOOL_CATEGORIES.values():
_providers = _cat.get("providers", [])
if provider in _providers and any(sib.get("managed_nous_feature") for sib in _providers):
return not get_nous_subscription_features(config, force_fresh=force_fresh).nous_auth_present
except Exception:
pass
return False
def _prompt_secret(
key: str, label: str, url: str, default_val: str, *, reconfigure: bool, url_label: str, strip: bool = False,
) -> bool:
"""One env-var prompt; True unless the new-enable flow skipped the key.
Reconfigure mode shows the current value and re-prompts ("Enter to keep current"); the new-enable
flow prompts with ``default_val`` visible when one exists, else as a password, and ``strip`` decides
whether whitespace is trimmed (and whitespace-only counts as skipped)."""
existing = get_env_value(key) if reconfigure else ""
if existing:
_print_info(f" {key}: configured ({existing[:8]}...)")
if url:
_print_info(f" {url_label}: {url}")
if reconfigure:
value = (_prompt(f" {label} (Enter to keep current)", password=not default_val) or "").strip()
if value:
save_env_value(key, value)
_print_success(" Updated")
else:
_print_info(" Kept current")
return True
value = _prompt(f" {label}", default_val) if default_val else _prompt(f" {label}", password=True)
if strip:
value = (value or "").strip()
if value:
save_env_value(key, value)
_print_success(" Saved")
return True
_print_warning(" Skipped")
return False
def _prompt_env_vars(env_vars: list, *, reconfigure: bool) -> bool:
"""Prompt for a provider's env vars; True when every key ended up configured.
Reconfigure mode re-prompts every key and always returns True; the new-enable flow keeps already-set
keys without asking and reports False on any skipped key."""
all_configured = True
for var in env_vars:
if not reconfigure and get_env_value(var["key"]):
_print_success(f" {var['key']}: already configured")
continue
ok = _prompt_secret(var["key"], var.get("prompt", var["key"]), var.get("url", ""), var.get("default", ""),
reconfigure=reconfigure, url_label="Get yours at")
all_configured = all_configured and ok
return all_configured
def _configure_provider(provider: dict, config: dict, *, force_fresh: bool = True, reconfigure: bool = False):
"""Configure a single provider - prompt for API keys and set config.
``reconfigure=False`` (new-enable): already-set keys are kept without asking and the post-setup hook
only runs when every key was provided. ``reconfigure=True`` re-prompts every key and always runs the
hook."""
from hermes_cli.tools_config import _run_post_setup
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if not _nous_provider_gate(provider, config, managed_feature, force_fresh=force_fresh):
return
_print_provider_selection(provider, managed_feature, reconfigure=reconfigure)
# Shared with the GUI provider-select endpoint (apply_provider_selection): one source of truth for config writes.
_write_provider_config(provider, config, managed_feature=managed_feature)
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.")
_finish_provider_selection(provider, config, managed_feature)
return
if not reconfigure and _show_portal_hint(provider, config, managed_feature, force_fresh):
_print_info(" Available through Nous Portal subscription.")
all_configured = _prompt_env_vars(env_vars, reconfigure=reconfigure)
if provider.get("post_setup") and all_configured:
_run_post_setup(provider["post_setup"])
if all_configured:
if not reconfigure:
_print_success(f" {provider['name']} configured!")
_finish_provider_selection(provider, config, managed_feature)
def _reconfigure_provider(provider: dict, config: dict, *, force_fresh: bool = True):
"""Reconfigure a provider - update API keys."""
_configure_provider(provider, config, force_fresh=force_fresh, reconfigure=True)
def _configure_vision_backend() -> None:
"""Interactive vision-backend configuration (``auxiliary.vision.{provider,model,base_url}``).
Offers any authenticated provider + model (same surface as ``hermes model``) or a custom endpoint
rather than forcing OpenRouter. "Auto" leaves the keys empty so the resolver uses the main-model
fallback chain."""
from hermes_cli.tools_config import _cfg_section, _prompt_choice
print()
print(color(" Vision / Image Analysis needs a multimodal model.", Colors.YELLOW))
print(color(" Pick any provider + model (like /model), or let it auto-detect.", Colors.DIM))
choices = [
"Auto — use your main model / aggregator fallback (recommended)",
"Pick a provider and model",
"Custom OpenAI-compatible endpoint — base URL, API key, model",
"Skip"]
idx = _prompt_choice(" Configure vision backend", choices, 0)
config = load_config()
vision_cfg = _cfg_section(_cfg_section(config, "auxiliary"), "vision")
if idx == 0:
# Auto: clear any pinned override so the resolver auto-detects.
for key in ("provider", "model", "base_url", "api_key", "api_mode"):
vision_cfg.pop(key, None)
save_config(config)
_print_success(" Vision set to auto (main model / aggregator fallback)")
elif idx == 1:
_configure_vision_provider_model(config, vision_cfg)
elif idx == 2:
base_url = _prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
is_native_openai = base_url_hostname(base_url) == "api.openai.com"
key_label = " OPENAI_API_KEY" if is_native_openai else " API key"
api_key = _prompt(key_label, password=True)
if not (api_key and api_key.strip()):
_print_warning(" Skipped")
return
default_model = "gpt-4o-mini" if is_native_openai else ""
model = _prompt(f" Vision model{f' (blank for {default_model})' if default_model else ''}").strip() or default_model
save_env_value("OPENAI_API_KEY", api_key.strip())
# Only base_url + model go to config.yaml; the key is the secret. Pin provider="custom" so the resolver
# routes through this endpoint — at the "auto" default _resolve_task_provider_model ignores base_url
# unless paired with a config api_key.
vision_cfg["provider"] = "custom"
vision_cfg["base_url"] = base_url
if model:
vision_cfg["model"] = model
else:
vision_cfg.pop("model", None)
save_config(config)
_print_success(f" Vision set to custom endpoint{f' ({model})' if model else ''}")
else:
_print_info(" Skipped vision configuration")
def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None:
"""Provider + model picker for vision, mirroring the ``/model`` surface.
Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` aux-task
picker lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. Persists
``auxiliary.vision.provider`` + ``.model``."""
from hermes_cli.tools_config import _prompt_choice
try:
from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries
except Exception as exc: # pragma: no cover - import guard
_print_warning(f" Could not load provider list: {exc}")
return
current_provider = str(vision_cfg.get("provider") or "").strip()
current_model = str(vision_cfg.get("model") or "").strip()
current_base_url = str(vision_cfg.get("base_url") or "").strip()
try:
providers = build_aux_picker_rows(current_provider=current_provider, current_model=current_model,
current_base_url=current_base_url, max_models=40)
except Exception as exc:
_print_warning(f" Could not detect providers: {exc}")
providers = []
if not providers:
_print_warning(" No authenticated providers found. Configure a provider first "
"with `hermes model`, then re-run this.")
return
provider_labels = [label for _slug, label, _models in format_aux_picker_entries(
providers, current_provider=current_provider, current_base_url=current_base_url)] + ["Cancel"]
pidx = _prompt_choice(" Choose vision provider:", provider_labels, 0)
if pidx >= len(providers):
_print_info(" Cancelled")
return
chosen = providers[pidx]
slug = chosen.get("slug")
models = list(chosen.get("models", []))
midx = _prompt_choice(f" Choose vision model for {chosen.get('name') or slug}:", models + ["Type a custom model id…"], 0)
if midx < len(models):
model = models[midx]
else:
model = _prompt(" Model id").strip()
if not model:
_print_warning(" No model entered — cancelled")
return
vision_cfg["provider"] = slug
vision_cfg["model"] = model
# A provider selection supersedes any prior custom endpoint override.
vision_cfg.pop("base_url", None)
vision_cfg.pop("api_key", None)
save_config(config)
_print_success(f" Vision set to {slug} / {model}")
def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False):
"""Fallback for toolsets that just need env vars (no provider selection).
Vision has its own provider/model picker — run it directly so neither flow falls back to the generic
single-key prompt (which would re-ask for OPENROUTER_API_KEY)."""
from hermes_cli.tools_config import TOOLSET_ENV_REQUIREMENTS, _toolset_has_keys, _toolset_label
if ts_key == "vision":
if reconfigure or not _toolset_has_keys("vision"):
_configure_vision_backend()
return
requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
if not reconfigure:
requirements = [(var, url) for var, url in requirements if not get_env_value(var)]
if not requirements:
return
ts_label = _toolset_label(ts_key)
print()
if reconfigure:
print(color(f" {ts_label}:", Colors.CYAN))
else:
print(color(f" {ts_label} requires configuration:", Colors.YELLOW))
for var, url in requirements:
_prompt_secret(var, var, url, "", reconfigure=reconfigure, url_label="Get key at", strip=True)