1404 lines
58 KiB
Python
1404 lines
58 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 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")
|
|
|
|
# NOTE: tools_config-internal names (TOOL_CATEGORIES, _cfg_section, _prompt_choice,
|
|
# get_nous_subscription_features, post-setup hooks, ...) are imported lazily inside the
|
|
# functions that need them: tools_config re-imports this module, and tests patch those
|
|
# names on ``hermes_cli.tools_config``.
|
|
|
|
|
|
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]:
|
|
"""Build picker-row dicts from a plugin registry's providers.
|
|
|
|
Each row looks like a hardcoded ``TOOL_CATEGORIES`` provider row plus the ``marker_keys``
|
|
(all set to the provider's registry name) that route downstream config-writing / model-picker
|
|
code through the plugin registry. ``skip_builtin`` drops providers whose name shadows the
|
|
registry's ``_BUILTIN_NAMES`` (defence in depth). ``flatten_variants`` expands a schema's tier
|
|
``variants`` (e.g. Exa/Parallel free keyless vs paid SDK) 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:
|
|
continue
|
|
if 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", []),
|
|
}
|
|
for key in marker_keys:
|
|
row[key] = name
|
|
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). Marker semantics:
|
|
# image/video ``*_plugin_name`` route config writes + model pickers through the registry (video
|
|
# has no in-tree backend, so this is the only row source); web ``web_backend`` + ``web_search_plugin_name``
|
|
# (all bundled web providers are plugins; only the firecrawl setup-flow rows stay hardcoded);
|
|
# browser ``browser_provider`` is the legacy key written to ``browser.cloud_provider``; TTS rows
|
|
# render below the built-in rows and write ``tts.provider: <name>`` like them.
|
|
_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]:
|
|
module, markers, kwargs = _PLUGIN_PROVIDER_ROW_SPECS[category]
|
|
return _plugin_provider_rows(module, markers, **kwargs)
|
|
|
|
|
|
def _plugin_image_gen_providers() -> list[dict]:
|
|
"""Picker rows for plugin-registered image gen providers."""
|
|
return _plugin_rows_for("image_gen")
|
|
|
|
|
|
def _plugin_video_gen_providers() -> list[dict]:
|
|
"""Picker rows for plugin-registered video gen providers (the only Video Generation rows)."""
|
|
return _plugin_rows_for("video_gen")
|
|
|
|
|
|
def _plugin_web_search_providers() -> list[dict]:
|
|
"""Picker rows for plugin-registered web search providers."""
|
|
return _plugin_rows_for("web")
|
|
|
|
|
|
def _plugin_browser_providers() -> list[dict]:
|
|
"""Picker rows for plugin-registered cloud browser providers."""
|
|
return _plugin_rows_for("browser")
|
|
|
|
|
|
def _plugin_tts_providers() -> list[dict]:
|
|
"""Picker rows for plugin-registered TTS providers."""
|
|
return _plugin_rows_for("tts")
|
|
|
|
|
|
def web_provider_capabilities(backend: str) -> list:
|
|
"""Return the capabilities (``search`` / ``extract``) a web backend supports.
|
|
|
|
Consults the plugin registry's provider instance (``supports_search`` / ``supports_extract``) so
|
|
the Capabilities GUI can offer per-capability selection (``web.search_backend`` /
|
|
``web.extract_backend``) only where it makes sense — e.g. ddgs and brave-free are search-only.
|
|
"""
|
|
try:
|
|
from agent.web_search_registry import get_provider
|
|
|
|
provider = get_provider(backend)
|
|
if provider is not None:
|
|
caps = []
|
|
if provider.supports_search():
|
|
caps.append("search")
|
|
if provider.supports_extract():
|
|
caps.append("extract")
|
|
return caps
|
|
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]:
|
|
"""Return provider entries visible for the current auth/config state.
|
|
|
|
Nous-managed Tool Gateway rows (``managed_nous_feature``) are always shown — even to logged-out
|
|
/ unentitled users — so the picker advertises that the capability exists.
|
|
"""
|
|
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.
|
|
# Logged-out users still see it (advertising) and paid users are entitled to it.
|
|
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 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 provider.get("managed_nous_feature")
|
|
and not features.nous_auth_present
|
|
):
|
|
continue
|
|
if (
|
|
pool_only
|
|
and provider.get("managed_nous_feature") == "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 and browser they are the
|
|
# only real provider rows — the hardcoded leftovers are non-provider UX setup flows.
|
|
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:
|
|
"""Compute an honest readiness state for a provider picker row.
|
|
|
|
``features`` (a ``NousSubscriptionFeatures``) can be passed to avoid re-fetching portal state
|
|
per row. ``is_active`` is the completed-setup fallback signal for post_setup hooks with no
|
|
registered installed-check (selecting a row runs its hook, so the active row has been set up).
|
|
"""
|
|
from hermes_cli.tools_config import (
|
|
_POST_SETUP_READY,
|
|
_provider_env_ready,
|
|
_xai_credentials_present,
|
|
get_nous_subscription_features,
|
|
)
|
|
|
|
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 (e.g. the managed browser row needs the agent-browser CLI on this machine).
|
|
|
|
post_setup = provider.get("post_setup")
|
|
if post_setup:
|
|
if post_setup == "xai_grok":
|
|
return "ready" if _xai_credentials_present() 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:
|
|
# Flaky detection must not manufacture a warning state.
|
|
return "ready"
|
|
# 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, {})
|
|
return not isinstance(section, dict) or selection_key not in section
|
|
if ts_key == "image_gen":
|
|
# Satisfied by the in-tree FAL backend OR any available plugin image gen provider.
|
|
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)
|
|
if registry is None:
|
|
return False
|
|
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`` is the "Reconfigure an existing tool" flow: 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
|
|
|
|
icon = cat.get("icon", "")
|
|
name = cat["name"]
|
|
providers = _visible_providers(cat, config, force_fresh=force_fresh)
|
|
|
|
if len(providers) == 1:
|
|
provider = providers[0]
|
|
print()
|
|
print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
|
|
if not reconfigure:
|
|
if provider.get("tag"):
|
|
_print_info(f" {provider['tag']}")
|
|
if cat.get("setup_note"):
|
|
_print_info(f" {cat['setup_note']}")
|
|
_configure_provider(provider, config, force_fresh=force_fresh, reconfigure=reconfigure)
|
|
return
|
|
|
|
print()
|
|
title = "Choose a provider" if reconfigure else cat.get("setup_title", "Choose a provider")
|
|
print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN))
|
|
if not reconfigure and cat.get("setup_note"):
|
|
_print_info(f" {cat['setup_note']}")
|
|
print()
|
|
|
|
# Logged-in Nous users get a marker on rows included in their subscription so it is obvious
|
|
# which options cost extra vs. nothing on top of Nous.
|
|
_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:
|
|
_nous_logged_in = False
|
|
|
|
# Plain text labels only (no ANSI codes in menu items)
|
|
provider_choices = []
|
|
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]"
|
|
# Logged-in subscribers get the "included" star; everyone else a "via Nous Portal" hint
|
|
# so it is clear selecting the row triggers a Portal login.
|
|
sub_marker = ""
|
|
if not reconfigure and p.get("managed_nous_feature"):
|
|
if _nous_logged_in:
|
|
sub_marker = " ★ Included with your Nous subscription"
|
|
else:
|
|
sub_marker = " ★ 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:
|
|
"""Return True when a web picker row's tier matches the configured tier.
|
|
|
|
Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` name and differ only in
|
|
``web_tier``; the configured tier lives at ``web.provider_tier.<backend>`` (set on selection).
|
|
Rules: no ``web_tier`` on the row → tier-agnostic, 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 not isinstance(web_cfg, dict):
|
|
web_cfg = {}
|
|
tiers = web_cfg.get("provider_tier")
|
|
if not isinstance(tiers, dict):
|
|
tiers = {}
|
|
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")
|
|
|
|
|
|
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
|
|
|
|
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
|
feature = features.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
|
|
if provider.get("tts_provider"):
|
|
current = cfg_get(config, "tts", "provider")
|
|
return feature.managed_by_nous and current in {provider["tts_provider"], NOUS_MANAGED_PROVIDER}
|
|
if provider.get("stt_provider"):
|
|
current = cfg_get(config, "stt", "provider")
|
|
return feature.managed_by_nous and current in {provider["stt_provider"], NOUS_MANAGED_PROVIDER}
|
|
if "browser_provider" in provider:
|
|
# Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so
|
|
# the provider row stays active alongside the Browser Use row.
|
|
current = cfg_get(config, "browser", "cloud_provider")
|
|
return feature.managed_by_nous and current in {provider["browser_provider"], NOUS_MANAGED_PROVIDER}
|
|
if provider.get("web_backend"):
|
|
current = cfg_get(config, "web", "backend")
|
|
return (
|
|
feature.managed_by_nous
|
|
and current in {provider["web_backend"], NOUS_MANAGED_PROVIDER}
|
|
and _web_tier_matches(provider, config)
|
|
)
|
|
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 _tts_active(provider: dict, config: dict) -> bool:
|
|
return cfg_get(config, "tts", "provider") == provider["tts_provider"]
|
|
|
|
|
|
def _stt_active(provider: dict, config: dict) -> bool:
|
|
# Default stt.provider is "local" — an unset key means Local Whisper.
|
|
return (cfg_get(config, "stt", "provider") or "local") == provider["stt_provider"]
|
|
|
|
|
|
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 provider 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_active(provider: dict, config: dict) -> bool:
|
|
backend = cfg_get(config, "browser", "backend")
|
|
if backend is False:
|
|
backend = "off" # YAML 1.1: unquoted `off` parses as boolean False
|
|
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 _web_backend_active(provider: dict, config: dict) -> bool:
|
|
if cfg_get(config, "web", "backend") != provider["web_backend"]:
|
|
return False
|
|
return _web_tier_matches(provider, config)
|
|
|
|
|
|
def _computer_use_active(provider: dict, config: dict) -> bool:
|
|
return cfg_get(config, "computer_use", "backend") == provider["computer_use_backend"]
|
|
|
|
|
|
def _imagegen_backend_active(provider: dict, config: dict) -> bool:
|
|
image_cfg = config.get("image_gen", {})
|
|
if not isinstance(image_cfg, dict):
|
|
return False
|
|
return (
|
|
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.
|
|
# ``browser_provider`` is a membership test (a local row carries ``browser_provider: ""``),
|
|
# every other marker is a truthiness test.
|
|
_ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = (
|
|
("tts_provider", _tts_active),
|
|
("stt_provider", _stt_active),
|
|
("browser_provider", _browser_provider_active),
|
|
("browser_backend", _browser_backend_active),
|
|
("web_backend", _web_backend_active),
|
|
("computer_use_backend", _computer_use_active),
|
|
("imagegen_backend", _imagegen_backend_active),
|
|
)
|
|
_MEMBERSHIP_MARKERS = frozenset({"browser_provider"})
|
|
|
|
|
|
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")
|
|
plugin_name = provider.get("image_gen_plugin_name")
|
|
if plugin_name and not managed_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.
|
|
image_cfg = config.get("image_gen", {})
|
|
if not (isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name):
|
|
return False
|
|
# A direct-key entry is only active when the managed route is OFF.
|
|
return not is_truthy_value(image_cfg.get("use_gateway"), default=False)
|
|
|
|
video_plugin_name = provider.get("video_gen_plugin_name")
|
|
if video_plugin_name and not managed_feature:
|
|
video_cfg = config.get("video_gen", {})
|
|
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
|
|
|
|
if managed_feature:
|
|
return _managed_provider_active(provider, config, managed_feature, force_fresh)
|
|
|
|
for marker, check in _ACTIVE_CHECKS:
|
|
present = marker in provider if marker in _MEMBERSHIP_MARKERS else provider.get(marker)
|
|
if present:
|
|
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
|
|
# Fallback: env vars present → likely configured
|
|
if p.get("env_vars", []) and _provider_env_ready(p):
|
|
return i
|
|
return 0
|
|
|
|
|
|
# ─── Image Generation Model Pickers ───────────────────────────────────────────
|
|
#
|
|
# IMAGEGEN_BACKENDS: per-backend catalog (config_key = top-level config.yaml section, catalog_fn ->
|
|
# ({model_id: metadata}, default_model)). A TOOL_CATEGORIES row tagged `imagegen_backend: "<name>"`
|
|
# selects the catalog at picker time.
|
|
|
|
|
|
def _fal_model_catalog():
|
|
"""Lazy-load the FAL model catalog from the tool module."""
|
|
from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL
|
|
return FAL_MODELS, DEFAULT_MODEL
|
|
|
|
|
|
IMAGEGEN_BACKENDS = {
|
|
"fal": {
|
|
"display": "FAL.ai",
|
|
"config_key": "image_gen",
|
|
"catalog_fn": _fal_model_catalog,
|
|
},
|
|
}
|
|
|
|
|
|
def _plugin_model_catalog(registry_module: str, plugin_name: str):
|
|
"""Return ``(catalog_dict, default_model_id)`` for a plugin provider.
|
|
|
|
``catalog_dict`` is shaped like the legacy ``FAL_MODELS`` table so the picker code path is
|
|
shared. Returns ``({}, None)`` if the provider is unregistered or has no models.
|
|
"""
|
|
registry = _plugin_registry(registry_module)
|
|
if registry is None:
|
|
return {}, None
|
|
try:
|
|
provider = registry.get_provider(plugin_name)
|
|
except Exception:
|
|
return {}, None
|
|
if provider is None:
|
|
return {}, None
|
|
try:
|
|
models = provider.list_models() or []
|
|
default = provider.default_model()
|
|
except Exception:
|
|
return {}, None
|
|
catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m}
|
|
return catalog, default
|
|
|
|
|
|
def _plugin_image_gen_catalog(plugin_name: str):
|
|
"""``(catalog, default_model)`` for a plugin-registered image gen provider."""
|
|
return _plugin_model_catalog("agent.image_gen_registry", plugin_name)
|
|
|
|
|
|
def _plugin_video_gen_catalog(plugin_name: str):
|
|
"""``(catalog, default_model)`` for a plugin-registered video gen provider."""
|
|
return _plugin_model_catalog("agent.video_gen_registry", plugin_name)
|
|
|
|
|
|
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 that belongs 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']}} "
|
|
f"{'Speed':<{widths['speed']}} "
|
|
f"{'Strengths':<{widths['strengths']}} "
|
|
f"Price"
|
|
)
|
|
print(color(header, Colors.CYAN))
|
|
|
|
rows = []
|
|
for mid in ordered:
|
|
meta = catalog[mid]
|
|
row = (
|
|
f"{row_indent}{mid:<{widths['model']}} "
|
|
f"{meta.get('speed', ''):<{widths['speed']}} "
|
|
f"{meta.get('strengths', ''):<{widths['strengths']}} "
|
|
f"{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"]()
|
|
_pick_model_from_catalog(catalog, default_model, backend["config_key"], backend["display"], config)
|
|
|
|
|
|
def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None:
|
|
"""Prompt for a model from a plugin-registered image gen backend's catalog."""
|
|
catalog, default_model = _plugin_image_gen_catalog(plugin_name)
|
|
_pick_model_from_catalog(catalog, default_model, "image_gen", plugin_name, config)
|
|
|
|
|
|
def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None:
|
|
"""Prompt for a video gen model from a plugin's catalog."""
|
|
catalog, default_model = _plugin_video_gen_catalog(plugin_name)
|
|
# Video rows historically carry a two-space indent; kept for output parity.
|
|
_pick_model_from_catalog(catalog, default_model, "video_gen", plugin_name, config, row_indent=" ")
|
|
|
|
|
|
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
|
|
|
|
section = _cfg_section(config, section_name)
|
|
xai_cfg = _cfg_section(section, "xai")
|
|
storage_cfg = _cfg_section(xai_cfg, "storage")
|
|
|
|
_print_warning(
|
|
" xAI Imagine can store generated media and create reusable public URLs. "
|
|
"xAI may bill for stored files and public URL hosting."
|
|
)
|
|
idx = _prompt_choice(
|
|
" Stored public URLs:",
|
|
[
|
|
"Enable public URLs without automatic expiry (recommended)",
|
|
"Disable stored public URLs",
|
|
"Enable public URLs for 2 days",
|
|
],
|
|
default=0,
|
|
)
|
|
if idx == 1:
|
|
storage_cfg["enabled"] = False
|
|
_print_success(" xAI stored public URLs disabled")
|
|
elif idx == 2:
|
|
storage_cfg["enabled"] = True
|
|
storage_cfg["public_url"] = True
|
|
storage_cfg["expires_after"] = 2 * 24 * 60 * 60
|
|
_print_success(" xAI stored public URLs enabled for 2 days")
|
|
else:
|
|
storage_cfg["enabled"] = True
|
|
storage_cfg["public_url"] = True
|
|
storage_cfg["expires_after"] = None
|
|
_print_success(" xAI stored public URLs enabled without automatic expiry")
|
|
|
|
|
|
def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool) -> None:
|
|
"""Persist a plugin-backed image/video generation provider selection and run its model picker.
|
|
|
|
``use_gateway=True`` (Nous-managed pick) stores ``<section>.provider: nous``; BYOK picks store
|
|
the plugin name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot
|
|
override the fresh selection.
|
|
"""
|
|
from hermes_cli.tools_config import _cfg_section
|
|
|
|
cfg = _cfg_section(config, section)
|
|
cfg["provider"] = NOUS_MANAGED_PROVIDER if use_gateway else plugin_name
|
|
cfg.pop("use_gateway", None)
|
|
_print_success(f" {section}.provider set to: {cfg['provider']}")
|
|
if section == "image_gen":
|
|
_configure_imagegen_model_for_plugin(plugin_name, config)
|
|
else:
|
|
_configure_videogen_model_for_plugin(plugin_name, config)
|
|
if plugin_name == "xai":
|
|
_configure_xai_imagine_storage(section, config)
|
|
|
|
|
|
def _select_plugin_image_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
|
|
_select_plugin_gen_provider("image_gen", plugin_name, config, use_gateway=use_gateway)
|
|
|
|
|
|
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
|
|
_select_plugin_gen_provider("video_gen", plugin_name, config, use_gateway=use_gateway)
|
|
|
|
|
|
# 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
|
|
stt_cfg = _cfg_section(config, "stt")
|
|
prov_cfg = _cfg_section(stt_cfg, stt_provider)
|
|
model_key = _STT_MODEL_CONFIG_KEY.get(stt_provider, "model")
|
|
current = str(prov_cfg.get(model_key) or "").strip()
|
|
ordered = list(catalog)
|
|
default_idx = ordered.index(current) if current in ordered else 0
|
|
idx = _prompt_choice(" Select STT model:", ordered, default_idx)
|
|
chosen = ordered[idx]
|
|
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. Both the CLI and the GUI ``PUT .../provider`` endpoint call
|
|
through here so there is one code path. 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, _cfg_section
|
|
|
|
def _drop_use_gateway(section) -> None:
|
|
if isinstance(section, dict):
|
|
section.pop("use_gateway", None)
|
|
|
|
def _set_selection(section_key: str, name_key: str, vendor_value) -> None:
|
|
section = _cfg_section(config, section_key)
|
|
section[name_key] = NOUS_MANAGED_PROVIDER if managed_feature else vendor_value
|
|
_drop_use_gateway(section)
|
|
|
|
if provider.get("tts_provider"):
|
|
_set_selection("tts", "provider", provider["tts_provider"])
|
|
if provider.get("stt_provider"):
|
|
_set_selection("stt", "provider", provider["stt_provider"])
|
|
|
|
if "browser_provider" in provider:
|
|
bp = provider["browser_provider"]
|
|
browser_cfg = config.setdefault("browser", {})
|
|
if bp or managed_feature:
|
|
# Browser Use mode (browser.backend) composes with the provider — switching providers
|
|
# keeps the driver choice intact.
|
|
_set_selection("browser", "cloud_provider", bp)
|
|
else:
|
|
browser_cfg.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 like the provider does.
|
|
if provider.get("browser_engine"):
|
|
config.setdefault("browser", {})["engine"] = provider["browser_engine"]
|
|
|
|
if provider.get("web_backend"):
|
|
_set_selection("web", "backend", provider["web_backend"])
|
|
web_cfg = config.get("web")
|
|
if isinstance(web_cfg, dict):
|
|
if provider.get("web_tier"):
|
|
tiers = web_cfg.setdefault("provider_tier", {})
|
|
if isinstance(tiers, dict):
|
|
tiers[provider["web_backend"]] = provider["web_tier"]
|
|
else:
|
|
stale_tiers = web_cfg.get("provider_tier")
|
|
if isinstance(stale_tiers, dict):
|
|
stale_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 for categories 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
|
|
_drop_use_gateway(section)
|
|
elif not managed_feature:
|
|
# Non-gateway pick — clear any stale legacy use_gateway key on the category so the
|
|
# read-time shim cannot override it. 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:
|
|
_drop_use_gateway(config.get(section_key))
|
|
|
|
|
|
def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None:
|
|
"""Non-interactively persist a provider selection for a toolset.
|
|
|
|
Resolves ``provider_name`` among the rows the picker shows (:func:`_visible_providers`) and
|
|
writes the config keys only — API keys, post-setup hooks, auth gating and model pickers are
|
|
handled by separate GUI endpoints. Raises ``KeyError`` for an unknown toolset or provider.
|
|
"""
|
|
from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section
|
|
|
|
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:
|
|
section = _cfg_section(config, section_key)
|
|
section["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else vendor
|
|
section.pop("use_gateway", None)
|
|
|
|
|
|
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 (see ``_visible_providers``) 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."""
|
|
from hermes_cli.tools_config import _cfg_section
|
|
|
|
plugin_name = provider.get("image_gen_plugin_name")
|
|
if plugin_name:
|
|
_select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature))
|
|
return
|
|
video_plugin = provider.get("video_gen_plugin_name")
|
|
if video_plugin:
|
|
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
|
return
|
|
backend = provider.get("imagegen_backend")
|
|
if backend:
|
|
_configure_imagegen_model(backend, config)
|
|
# In-tree FAL is the only non-plugin backend. Persist "nous" for a managed row, "fal" for
|
|
# BYOK, and drop legacy use_gateway — never clobber a managed pick back onto direct keys.
|
|
img_cfg = _cfg_section(config, "image_gen")
|
|
img_cfg["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else "fal"
|
|
img_cfg.pop("use_gateway", None)
|
|
# 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"):
|
|
if reconfigure and provider.get("web_tier"):
|
|
_print_success(f" Web backend set to: {provider['web_backend']} ({provider['web_tier']} tier)")
|
|
else:
|
|
_print_success(f" Web backend set to: {provider['web_backend']}")
|
|
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_env_vars(env_vars: list, *, reconfigure: bool) -> bool:
|
|
"""Prompt for a provider's env vars; returns True when every key ended up configured.
|
|
|
|
Reconfigure mode re-prompts every key ("Enter to keep current") 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:
|
|
existing = get_env_value(var["key"])
|
|
if reconfigure:
|
|
if existing:
|
|
_print_info(f" {var['key']}: configured ({existing[:8]}...)")
|
|
url = var.get("url", "")
|
|
if url:
|
|
_print_info(f" Get yours at: {url}")
|
|
default_val = var.get("default", "")
|
|
value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val)
|
|
if value and value.strip():
|
|
save_env_value(var["key"], value.strip())
|
|
_print_success(" Updated")
|
|
else:
|
|
_print_info(" Kept current")
|
|
continue
|
|
if existing:
|
|
_print_success(f" {var['key']}: already configured")
|
|
continue
|
|
url = var.get("url", "")
|
|
if url:
|
|
_print_info(f" Get yours at: {url}")
|
|
default_val = var.get("default", "")
|
|
if default_val:
|
|
value = _prompt(f" {var.get('prompt', var['key'])}", default_val)
|
|
else:
|
|
value = _prompt(f" {var.get('prompt', var['key'])}", password=True)
|
|
if value:
|
|
save_env_value(var["key"], value)
|
|
_print_success(" Saved")
|
|
else:
|
|
_print_warning(" Skipped")
|
|
all_configured = False
|
|
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`` is the new-enable flow: 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 ("Enter to keep current") and always runs the post-setup 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): single source of
|
|
# truth for the config-key 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.
|
|
|
|
Vision resolves from ``auxiliary.vision.{provider,model,base_url}``. Rather than forcing
|
|
OpenRouter, offer any authenticated provider + model (same surface as ``hermes model``) or a
|
|
custom endpoint. "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)")
|
|
return
|
|
|
|
if idx == 1:
|
|
_configure_vision_provider_model(config, vision_cfg)
|
|
return
|
|
|
|
if 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 ''}")
|
|
return
|
|
|
|
# Skip
|
|
_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,
|
|
)
|
|
]
|
|
provider_labels.append("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", []))
|
|
|
|
model_choices = list(models) + ["Type a custom model id…"]
|
|
midx = _prompt_choice(f" Choose vision model for {chosen.get('name') or slug}:", model_choices, 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 (any provider, like ``hermes model``) — 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:
|
|
if reconfigure:
|
|
existing = get_env_value(var)
|
|
if existing:
|
|
_print_info(f" {var}: configured ({existing[:8]}...)")
|
|
if url:
|
|
_print_info(f" Get key at: {url}")
|
|
suffix = " (Enter to keep current)" if reconfigure else ""
|
|
value = _prompt(f" {var}{suffix}", password=True)
|
|
if value and value.strip():
|
|
save_env_value(var, value.strip())
|
|
_print_success(" Updated" if reconfigure else " Saved")
|
|
else:
|
|
if reconfigure:
|
|
_print_info(" Kept current")
|
|
else:
|
|
_print_warning(" Skipped")
|
|
|
|
|