Files
hermes-agent/hermes_cli/nous_subscription.py
T

1116 lines
46 KiB
Python

"""Helpers for Nous subscription managed-tool capabilities."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.config import get_env_value, load_config
from hermes_cli.nous_account import (
NousPortalAccountInfo,
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from utils import is_truthy_value
from tools.tool_backend_helpers import (
fal_key_is_configured,
has_direct_modal_credentials,
managed_nous_tools_enabled, # noqa: F401 (test-patchable re-export)
normalize_browser_cloud_provider,
normalize_modal_mode,
resolve_modal_backend_state,
resolve_openai_audio_api_key,
)
_DEFAULT_PLATFORM_TOOLSETS = {
"cli": "hermes-cli",
}
@dataclass(frozen=True)
class _FeatureSpec:
"""Per-feature parameters shared by the status, defaults and Tool Gateway offer surfaces."""
label: str
included_by_default: bool
# Tool-pool coverage category (hermes_cli.nous_account.TOOL_COVERAGE_CATEGORIES). Lets the
# `hermes tools` picker scope its entitlement gate to the selected backend, so a free-tool-pool
# user is allowed image gen but denied video gen at select time — consistent with the
# per-category feature gates in get_nous_subscription_features. STT shares the TTS category:
# both ride the managed "openai-audio" gateway endpoint (speech + transcriptions).
coverage: str
# Managed gateway probed for readiness. Video rides image's fal-queue gateway but is gated on
# its own coverage category: the free tool pool funds image and NOT video (paid users get both).
gateway: str
# Config section + selection field written by apply_gateway_defaults and read by
# get_nous_subscription_features (web uses "backend", browser "cloud_provider", else "provider").
# None = not offered by the Tool Gateway prompt (modal).
section_field: Optional[tuple[str, str]] = None
offer_label: str = ""
direct_label: str = ""
# Direct-credential env vars that keep apply_nous_managed_defaults from switching the
# category to the managed selection (tts/stt also honour resolve_openai_audio_api_key()).
default_direct_env: tuple[str, ...] = ()
_FEATURES: Dict[str, _FeatureSpec] = {
"web": _FeatureSpec(
"Web tools", True, "firecrawl", "firecrawl", ("web", "backend"),
"Web search & extract (Firecrawl)", "Firecrawl/Exa/Parallel/Keenable key or SearXNG",
("PARALLEL_API_KEY", "TAVILY_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"),
),
"image_gen": _FeatureSpec(
"Image generation", True, "fal", "fal-queue", ("image_gen", "provider"),
"Image generation (FAL)", "FAL key",
),
"video_gen": _FeatureSpec(
"Video generation", False, "fal-video", "fal-queue", ("video_gen", "provider"),
"Video generation (FAL)", "FAL key",
),
"tts": _FeatureSpec(
"OpenAI TTS", True, "openai-audio", "openai-audio", ("tts", "provider"),
"Text-to-speech (OpenAI TTS)", "OpenAI/ElevenLabs key", ("ELEVENLABS_API_KEY",),
),
"stt": _FeatureSpec(
"Speech-to-text", True, "openai-audio", "openai-audio", ("stt", "provider"),
"Speech-to-text (OpenAI Whisper)", "OpenAI/Groq/Mistral key", ("GROQ_API_KEY", "MISTRAL_API_KEY"),
),
"browser": _FeatureSpec(
"Browser automation", True, "browser-use", "browser-use", ("browser", "cloud_provider"),
"Browser automation (Browser Use)", "Browser Use/Browserbase key or Camofox",
("BROWSER_USE_API_KEY", "BROWSERBASE_API_KEY"),
),
"modal": _FeatureSpec("Modal execution", False, "modal", "modal"),
}
_FEATURE_ORDER = tuple(_FEATURES)
# Public / test-referenced views over the table.
MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {k: s.coverage for k, s in _FEATURES.items()}
_GATEWAY_SECTION_FIELDS = {k: s.section_field for k, s in _FEATURES.items() if s.section_field}
_ALL_GATEWAY_KEYS = tuple(_GATEWAY_SECTION_FIELDS)
_GATEWAY_TOOL_LABELS = {k: _FEATURES[k].offer_label for k in _ALL_GATEWAY_KEYS}
def _uses_gateway(section: object) -> bool:
"""Return True when a config section explicitly opts into the gateway."""
if not isinstance(section, dict):
return False
return is_truthy_value(section.get("use_gateway"), default=False)
def _selected_provider(section: object, name_key: str = "provider") -> Optional[str]:
"""Return the stored provider string for a config section dict.
Mirrors :func:`tools.tool_backend_helpers.read_selection`'s semantics on an in-memory section
dict: ``"nous"`` for the managed selection (stored ``nous`` value or legacy ``use_gateway:
true``), a vendor name for BYOK picks, or ``None`` when no selection is stored.
"""
if not isinstance(section, dict):
return None
if is_truthy_value(section.get("use_gateway"), default=False):
return "nous"
value = section.get(name_key)
if value is None:
return None
name = str(value).strip().lower()
return name or None
@dataclass(frozen=True)
class NousFeatureState:
key: str
label: str
included_by_default: bool
available: bool
active: bool
managed_by_nous: bool
direct_override: bool
toolset_enabled: bool
current_provider: str = ""
explicit_configured: bool = False
@dataclass(frozen=True)
class NousSubscriptionFeatures:
subscribed: bool
nous_auth_present: bool
provider_is_nous: bool
features: Dict[str, NousFeatureState]
account_info: Optional[NousPortalAccountInfo] = None
def __getattr__(self, name: str) -> NousFeatureState:
# ``features.web`` / ``features.tts`` … resolve to the per-key state.
if name in _FEATURE_ORDER:
return self.features[name]
raise AttributeError(name)
def items(self) -> Iterable[NousFeatureState]:
for key in _FEATURE_ORDER:
yield self.features[key]
def _section(config: Dict[str, object], key: str) -> Dict[str, object]:
"""Return ``config[key]`` when it is a dict, else ``{}`` (read-only view)."""
value = config.get(key)
return value if isinstance(value, dict) else {}
def _ensure_section(config: Dict[str, object], key: str) -> Dict[str, object]:
"""Return ``config[key]`` as a dict, creating/replacing it in ``config`` when missing."""
value = config.get(key)
if not isinstance(value, dict):
value = {}
config[key] = value
return value
def _select_nous(config: Dict[str, object], key: str) -> None:
"""Store the managed ``nous`` selection in the ``key`` section (field per _GATEWAY_SECTION_FIELDS)."""
section_key, field = _GATEWAY_SECTION_FIELDS[key]
section = _ensure_section(config, section_key)
section[field] = "nous"
section.pop("use_gateway", None)
def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]:
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
return dict(model_cfg)
if isinstance(model_cfg, str) and model_cfg.strip():
return {"default": model_cfg.strip()}
return {}
def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
from toolsets import resolve_toolset
platform_toolsets = config.get("platform_toolsets")
if not isinstance(platform_toolsets, dict) or not platform_toolsets:
platform_toolsets = {"cli": [_DEFAULT_PLATFORM_TOOLSETS["cli"]]}
target_tools = set(resolve_toolset(toolset_key))
if not target_tools:
return False
for platform, raw_toolsets in platform_toolsets.items():
toolset_names = list(raw_toolsets) if isinstance(raw_toolsets, list) else []
if not toolset_names:
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
toolset_names = [default_toolset] if default_toolset else []
available_tools: Set[str] = set()
for toolset_name in toolset_names:
if isinstance(toolset_name, str) and toolset_name:
try:
available_tools.update(resolve_toolset(toolset_name))
except Exception:
continue
if target_tools.issubset(available_tools):
return True
return False
def _has_agent_browser() -> bool:
import shutil
from hermes_constants import agent_browser_runnable
# agent-browser is no longer a root package.json dependency (#43564) — it
# resolves lazily via npx for most installs, which a bare PATH +
# node_modules probe can't see. Mirror the local-CLI tail of
# :func:`tools.browser_tool.check_browser_requirements` (same cascade, same
# Termux carve-out) so the setup/status surfaces can't diverge from what
# browser tools actually find at runtime; validate=False keeps this a cheap
# existence check with no subprocess spawn.
try:
from tools.browser_tool import (
_find_agent_browser,
_requires_real_termux_browser_install,
)
except Exception:
# If the runtime probe can't be imported, fall back to binary presence
# (prior behaviour) rather than crashing the setup/status surface.
# Validate the resolved binary actually runs — a dangling global
# symlink (issue #48521) is reported by ``which`` but fails at exec.
from hermes_constants import with_hermes_node_path
# Rungs: PATH; Hermes-managed Node dirs (Windows installer / POSIX
# $HERMES_HOME/node — prepended to PATH at runtime but usually absent
# from the *probe* process's PATH, without which a successful install
# keeps reporting "needs setup" on Windows); local node_modules/.bin
# (PATHEXT-aware ``shutil.which`` so Windows picks the executable
# ``.cmd`` shim — probing the extensionless POSIX shim directly fails
# exec (WinError 193) even right after a successful ``npm install``).
local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin"
search_paths = [
None,
with_hermes_node_path().get("PATH", ""),
str(local_bin_dir) if local_bin_dir.is_dir() else "",
]
for path in search_paths:
if path == "":
continue
hit = shutil.which("agent-browser") if path is None else shutil.which("agent-browser", path=path)
if hit and agent_browser_runnable(hit):
return True
return False
try:
browser_cmd = _find_agent_browser(validate=False)
except FileNotFoundError:
return False
# On Termux, the bare npx fallback is too fragile to advertise as ready —
# require a real install, matching check_browser_requirements.
return not _requires_real_termux_browser_install(browser_cmd)
def _local_browser_runnable() -> bool:
"""Return True when the *local* browser backend would actually start.
The ``agent-browser`` CLI being present is necessary but not sufficient for local mode: agent-
browser also needs a Chromium build on disk (without one it hangs on first use until the command
timeout fires), unless the Lightpanda engine is selected — text-only navigation needs no
Chromium.
This mirrors the local-mode tail of :func:`tools.browser_tool.check_browser_requirements`, so
the setup/status surfaces advertise local browser readiness only when the runtime would actually
run it.
"""
if not _has_agent_browser():
return False
try:
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
except Exception:
# If the runtime probe can't be imported, fall back to binary presence
# (prior behaviour) rather than crashing the setup/status surface.
return True
return _using_lightpanda_engine() or _chromium_installed()
_PROVIDER_LABELS = {
"browser": ("local", {
"browserbase": "Browserbase",
"browser-use": "Browser Use",
"firecrawl": "Firecrawl",
"camofox": "Camofox",
"local": "Local browser",
}),
"tts": ("edge", {
"openai": "OpenAI TTS",
"elevenlabs": "ElevenLabs",
"edge": "Edge TTS",
"xai": "xAI TTS",
"mistral": "Mistral Voxtral TTS",
"neutts": "NeuTTS",
}),
"stt": ("local", {
"openai": "OpenAI Whisper",
"groq": "Groq Whisper",
"mistral": "Mistral Voxtral Transcribe",
"local": "Local faster-whisper",
}),
}
def _provider_label(kind: str, current_provider: str) -> str:
default, mapping = _PROVIDER_LABELS[kind]
return mapping.get(current_provider or default, current_provider or mapping[default])
def _local_stt_backend_available() -> bool:
"""Whether a local STT backend could serve transcription right now.
True when faster-whisper imports or a custom local STT command is configured. Also stops
``apply_nous_managed_defaults`` from flipping a working local setup to the managed gateway.
"""
if get_env_value("HERMES_LOCAL_STT_COMMAND"):
return True
try:
from tools.transcription_tools import _HAS_FASTER_WHISPER
return bool(_HAS_FASTER_WHISPER)
except Exception:
return False
def _resolve_browser_feature_state(
*,
browser_tool_enabled: bool,
browser_provider: str,
browser_provider_explicit: bool,
browser_local_available: bool,
browser_local_runnable: bool,
direct_camofox: bool,
direct_browserbase: bool,
direct_browser_use: bool,
direct_firecrawl: bool,
managed_browser_available: bool,
) -> tuple[str, bool, bool, bool]:
"""Resolve browser availability using the same precedence as runtime.
``browser_local_available`` means "the agent-browser CLI is present" — the only local
requirement for cloud providers, which host their own Chromium.
"""
browser_use_managed = bool(
browser_tool_enabled
and browser_local_available
and managed_browser_available
and not direct_browser_use
)
if browser_provider_explicit:
# Camofox is a stored selection (browser.cloud_provider: camofox);
# CAMOFOX_URL is only the server address.
cloud_available = {
"camofox": direct_camofox,
"browserbase": browser_local_available and direct_browserbase,
"browser-use": browser_local_available and (managed_browser_available or direct_browser_use),
"firecrawl": browser_local_available and direct_firecrawl,
}
current_provider = browser_provider or "local"
if current_provider not in cloud_available:
current_provider = "local"
available = bool(cloud_available.get(current_provider, browser_local_runnable))
managed = browser_use_managed if current_provider == "browser-use" else False
# Never-configured autodetect: CAMOFOX_URL keeps activating Camofox
# exactly as before when no cloud_provider selection was ever stored.
elif direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
elif managed_browser_available or direct_browser_use:
current_provider, available, managed = "browser-use", bool(browser_local_available), browser_use_managed
elif direct_browserbase:
current_provider, available, managed = "browserbase", bool(browser_local_available), False
else:
current_provider, available, managed = "local", bool(browser_local_runnable), False
return current_provider, available, bool(browser_tool_enabled and available), managed
def _any_env(*names: str) -> bool:
"""True when any of the named env vars (via get_env_value) is set."""
return any(get_env_value(name) for name in names)
def _fal_provider_label(selected: Optional[str], direct_fal: bool, managed: bool) -> str:
if selected not in (None, "nous") or (selected is None and direct_fal):
return "FAL"
return "Nous Subscription" if (managed or selected == "nous") else ""
def _account_info_or_none(**kwargs) -> Optional[NousPortalAccountInfo]:
"""``get_nous_portal_account_info(**kwargs)``, failing closed to ``None`` on any error."""
try:
return get_nous_portal_account_info(**kwargs)
except Exception:
return None
def get_nous_subscription_features(
config: Optional[Dict[str, object]] = None,
*,
force_fresh: bool = False,
) -> NousSubscriptionFeatures:
if config is None:
config = load_config() or {}
config = dict(config)
model_cfg = _model_config_dict(config)
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
account_info = _account_info_or_none(**({"force_fresh": True} if force_fresh else {}))
# Coarse "entitled to any managed tool" gate: paid access OR a live free
# tool pool. Per-backend availability is then narrowed by coverage below
# (the pool funds image but not video, etc.).
managed_tools_flag = bool(
account_info
and account_info.logged_in
and account_info.tool_gateway_entitled
)
nous_auth_present = bool(account_info and account_info.logged_in)
subscribed = provider_is_nous or nous_auth_present
enabled = {key: _toolset_enabled(config, key) for key in ("web", "image_gen", "video_gen", "tts", "browser", "terminal")}
web_tool_enabled, image_tool_enabled, video_tool_enabled = enabled["web"], enabled["image_gen"], enabled["video_gen"]
tts_tool_enabled, browser_tool_enabled, modal_tool_enabled = enabled["tts"], enabled["browser"], enabled["terminal"]
web_cfg, tts_cfg, stt_cfg, browser_cfg, terminal_cfg = (
_section(config, key) for key in ("web", "tts", "stt", "browser", "terminal")
)
web_backend = str(web_cfg.get("backend") or "").strip().lower()
# Per-capability overrides: if set, they determine which backend is active for
# search/extract independently of web.backend.
web_search_backend = str(web_cfg.get("search_backend") or "").strip().lower()
web_extract_backend = str(web_cfg.get("extract_backend") or "").strip().lower()
tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower()
# STT default is "local" (faster-whisper) per DEFAULT_CONFIG, which
# requires `pip install faster-whisper`. For Nous subscribers we'd
# rather route through the managed OpenAI audio gateway — see
# apply_nous_managed_defaults below.
stt_provider = str(stt_cfg.get("provider") or "local").strip().lower()
browser_provider_explicit = "cloud_provider" in browser_cfg
browser_provider = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider") if browser_provider_explicit else None
)
terminal_backend = str(terminal_cfg.get("backend") or "local").strip().lower()
modal_mode = normalize_modal_mode(terminal_cfg.get("modal_mode"))
# Stored selections (strict model): one provider string per category.
# "nous" (stored value or legacy use_gateway: true) = managed gateway;
# vendor name = that vendor direct; None = never configured (autodetect).
# Lockstep with tools.tool_backend_helpers.read_selection: these are
# merged-config sections, so the legacy DEFAULT_CONFIG-seeded
# ``stt.provider: local`` COULD appear here without a user pick on old
# versions. Current DEFAULT_CONFIG no longer seeds it, so a merged
# ``local`` implies the raw file holds it — a genuine selection.
selected = {
key: _selected_provider(_section(config, section_key), field)
for key, (section_key, field) in _GATEWAY_SECTION_FIELDS.items()
}
# Managed selection flags (use_gateway is interpreted only inside _selected_provider).
use_gateway = {key: value == "nous" for key, value in selected.items()}
web_gw, image_gw, video_gw = use_gateway["web"], use_gateway["image_gen"], use_gateway["video_gen"]
tts_gw, stt_gw, browser_gw = use_gateway["tts"], use_gateway["stt"], use_gateway["browser"]
# The "nous" selection is serviced by a concrete vendor implementation —
# normalize the current-provider labels so downstream vendor checks hold.
if web_backend == "nous" or web_gw:
web_backend = "firecrawl"
if tts_provider == "nous" or tts_gw:
tts_provider = "openai"
if stt_provider == "nous" or stt_gw:
stt_provider = "openai"
if browser_provider == "nous" or browser_gw:
browser_provider = "browser-use"
# Direct credentials. When the managed selection is stored for a category,
# its direct credentials are suppressed for managed detection.
direct_exa = _any_env("EXA_API_KEY") and not web_gw
direct_firecrawl = _any_env("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL") and not web_gw
direct_parallel = _any_env("PARALLEL_API_KEY") and not web_gw
direct_tavily = _any_env("TAVILY_API_KEY") and not web_gw
# Keyless Tavily is opt-in: selecting it in `hermes tools` / setup writes
# web.backend (or a per-capability override) without requiring a key.
tavily_selected = "tavily" in {web_backend, web_search_backend, web_extract_backend} and not web_gw
direct_searxng = _any_env("SEARXNG_URL")
fal_configured = fal_key_is_configured()
direct_fal = fal_configured and not image_gw
direct_fal_video = fal_configured and not video_gw # same FAL_KEY; separate var so use_gateway is independent
# OpenAI Whisper reuses the same audio key as OpenAI TTS —
# resolve_openai_audio_api_key() reads VOICE_TOOLS_OPENAI_KEY and falls
# back to OPENAI_API_KEY.
audio_key = bool(resolve_openai_audio_api_key())
direct_openai_tts = audio_key and not tts_gw
direct_elevenlabs = _any_env("ELEVENLABS_API_KEY") and not tts_gw
direct_camofox = _any_env("CAMOFOX_URL")
direct_browserbase = (
bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) and not browser_gw
)
direct_browser_use = _any_env("BROWSER_USE_API_KEY") and not browser_gw
direct_modal = has_direct_modal_credentials()
# STT direct providers. The local provider's "direct" signal is whether
# faster-whisper is importable (lazy-imported so this module stays cheap).
direct_openai_stt = audio_key and not stt_gw
direct_groq_stt = _any_env("GROQ_API_KEY") and not stt_gw
direct_mistral_stt = _any_env("MISTRAL_API_KEY") and not stt_gw
local_stt_available = _local_stt_backend_available() and not stt_gw
# Managed availability per feature. Strict selection: a stored VENDOR
# selection pins the category to direct credentials — managed availability
# must not light the feature up (the runtime will error, not reroute).
managed = {
key: (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready(_FEATURES[key].gateway)
and bool(account_info and account_info.tool_gateway_entitled_for(_FEATURES[key].coverage))
and (selected.get(key) is None or use_gateway[key])
)
for key in _FEATURE_ORDER
}
modal_state = resolve_modal_backend_state(
modal_mode,
has_direct=direct_modal,
managed_ready=managed["modal"],
managed_enabled=managed_tools_flag,
)
if selected["browser"] is not None and selected["browser"] != "camofox":
# CAMOFOX_URL is the server address, not a selection: an explicit
# different browser choice wins over the env var.
direct_camofox = False
# Direct web readiness per vendor. web.backend and the per-capability
# overrides (search_backend / extract_backend, split config from #20061)
# may each name a vendor; extract_backend only supports tavily.
web_direct = {
"exa": direct_exa,
"firecrawl": direct_firecrawl,
"parallel": direct_parallel,
"tavily": direct_tavily or tavily_selected,
"searxng": direct_searxng,
}
web_managed = web_backend == "firecrawl" and managed["web"] and not direct_firecrawl
web_active = bool(
web_tool_enabled
and (
web_managed
or web_direct.get(web_backend)
or web_direct.get(web_search_backend)
or (web_extract_backend == "tavily" and web_direct["tavily"])
)
)
web_available = bool(managed["web"] or any(web_direct.values()))
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
and tts_current_provider == "openai"
and managed["tts"]
and not direct_openai_tts
)
tts_available = bool({
"edge": True,
"neutts": True,
"openai": managed["tts"] or direct_openai_tts,
"elevenlabs": direct_elevenlabs,
"mistral": _any_env("MISTRAL_API_KEY"),
}.get(tts_current_provider, False))
tts_active = bool(tts_tool_enabled and tts_available)
# STT availability per provider. Unlike TTS, STT isn't a model-callable
# tool — the gateway voice middleware calls it on every inbound voice
# message — so toolset_enabled is N/A and we treat stt as always
# "enabled" if a usable provider is configured.
stt_current_provider = stt_provider or "local"
stt_managed = (
stt_current_provider == "openai"
and managed["stt"]
and not direct_openai_stt
)
stt_available = bool({
"local": local_stt_available,
"openai": managed["stt"] or direct_openai_stt,
"groq": direct_groq_stt,
"mistral": direct_mistral_stt,
}.get(stt_current_provider, False))
browser_local_available = _has_agent_browser()
(
browser_current_provider,
browser_available,
browser_active,
browser_managed,
) = _resolve_browser_feature_state(
browser_tool_enabled=browser_tool_enabled,
browser_provider=browser_provider,
browser_provider_explicit=browser_provider_explicit,
browser_local_available=browser_local_available,
browser_local_runnable=_local_browser_runnable(),
direct_camofox=direct_camofox,
direct_browserbase=direct_browserbase,
direct_browser_use=direct_browser_use,
direct_firecrawl=direct_firecrawl,
managed_browser_available=managed["browser"],
)
# Modal: a non-modal terminal backend, or a resolved managed/direct
# selection, is always "available"; otherwise report what the mode could use.
modal_selected = modal_state["selected_backend"] if terminal_backend == "modal" else None
if terminal_backend != "modal" or modal_selected in ("managed", "direct"):
modal_available, modal_active = True, bool(modal_tool_enabled)
modal_managed = modal_selected == "managed" and bool(modal_tool_enabled)
modal_direct_override = modal_selected == "direct" and bool(modal_tool_enabled)
else:
modal_managed = modal_direct_override = modal_active = False
modal_available = bool(
{"managed": managed["modal"], "direct": direct_modal}.get(
modal_mode, managed["modal"] or direct_modal
)
)
def _state(key: str, **fields) -> NousFeatureState:
spec = _FEATURES[key]
fields.setdefault("direct_override", fields["active"] and not fields["managed_by_nous"])
return NousFeatureState(key=key, label=spec.label, included_by_default=spec.included_by_default, **fields)
def _fal_state(key: str, tool_enabled: bool, direct: bool) -> NousFeatureState:
# image_gen / video_gen: same FAL_KEY, independently gated managed availability.
fal_managed = tool_enabled and managed[key] and not direct
return _state(
key,
available=bool(managed[key] or direct),
active=bool(tool_enabled and (fal_managed or direct)),
managed_by_nous=fal_managed,
toolset_enabled=tool_enabled,
current_provider=_fal_provider_label(selected[key], direct, fal_managed),
explicit_configured=selected[key] is not None or direct,
)
features = {
"web": _state(
"web",
available=web_available,
active=web_active,
managed_by_nous=web_managed,
toolset_enabled=web_tool_enabled,
current_provider=web_backend or web_search_backend or web_extract_backend or "",
explicit_configured=bool(web_backend or web_search_backend or web_extract_backend),
),
"image_gen": _fal_state("image_gen", image_tool_enabled, direct_fal),
"video_gen": _fal_state("video_gen", video_tool_enabled, direct_fal_video),
"tts": _state(
"tts",
available=tts_available,
active=tts_active,
managed_by_nous=tts_managed,
toolset_enabled=tts_tool_enabled,
current_provider=_provider_label("tts", tts_current_provider),
# Explicit-configured mirrors the stored selections so status/picker
# markers stay in lockstep with runtime dispatch.
explicit_configured=selected["tts"] is not None and selected["tts"] != "edge",
),
"stt": _state(
"stt",
available=stt_available,
active=stt_available,
managed_by_nous=stt_managed,
# STT isn't toolset-gated (gateway middleware calls it
# unconditionally on inbound voice), so report True so the
# status display doesn't flag it as "tool disabled".
toolset_enabled=True,
current_provider=_provider_label("stt", stt_current_provider),
explicit_configured=selected["stt"] is not None,
),
"browser": _state(
"browser",
available=browser_available,
active=browser_active,
managed_by_nous=browser_managed,
toolset_enabled=browser_tool_enabled,
current_provider=_provider_label("browser", browser_current_provider),
explicit_configured=browser_provider_explicit,
),
"modal": _state(
"modal",
available=modal_available,
active=modal_active,
managed_by_nous=modal_managed,
direct_override=terminal_backend == "modal" and modal_direct_override,
toolset_enabled=modal_tool_enabled,
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
explicit_configured=terminal_backend == "modal",
),
}
return NousSubscriptionFeatures(
subscribed=subscribed,
nous_auth_present=nous_auth_present,
provider_is_nous=provider_is_nous,
features=features,
account_info=account_info,
)
def _has_managed_default_direct(key: str) -> bool:
if key in ("tts", "stt") and resolve_openai_audio_api_key():
return True
return _any_env(*_FEATURES[key].default_direct_env)
def apply_nous_managed_defaults(
config: Dict[str, object],
*,
enabled_toolsets: Optional[Iterable[str]] = None,
force_fresh: bool = False,
) -> set[str]:
features = get_nous_subscription_features(config, force_fresh=force_fresh)
account_info = features.account_info
if not (
account_info
and account_info.logged_in
and account_info.tool_gateway_entitled
and features.provider_is_nous
):
return set()
selected_toolsets = set(enabled_toolsets or ())
changed: set[str] = set()
for key in ("web", "tts", "stt", "browser"):
_ensure_section(config, key)
for key in ("web", "tts", "stt", "browser"):
if features.features[key].explicit_configured or _has_managed_default_direct(key):
continue
if key == "stt":
# STT: same pattern as TTS. The DEFAULT_CONFIG seed is "local"
# (requires `pip install faster-whisper`); for Nous subscribers we
# flip it to the managed selection so the managed audio gateway handles
# transcription via the same auth as TTS. Not toolset-gated. Skipped when
# the user has a working local backend (faster-whisper installed or a
# custom local command — strong intent signal that "local" was a choice,
# not just the DEFAULT_CONFIG seed), or isn't entitled to the managed
# "openai-audio" category (flipping would point at a gateway that
# refuses them, silently breaking voice transcription).
if _local_stt_backend_available() or not (
account_info is not None and account_info.tool_gateway_entitled_for("openai-audio")
):
continue
elif key not in selected_toolsets:
continue
_select_nous(config, key)
changed.add(key)
# Video gen is not funded by the free tool pool, so only wire managed video
# defaults for users entitled to it (paid). Pool-only users keep video off.
for key, category in (("image_gen", None), ("video_gen", "fal-video")):
if (
key in selected_toolsets
and not fal_key_is_configured()
and (category is None or account_info.tool_gateway_entitled_for(category))
):
_select_nous(config, key)
changed.add(key)
return changed
# ---------------------------------------------------------------------------
# Tool Gateway offer — single Y/n prompt after model selection
# ---------------------------------------------------------------------------
def _get_gateway_direct_credentials() -> Dict[str, bool]:
"""Return a dict of tool_key -> has_direct_credentials.
Env-configured keyless local backends count as configured: a reachable self-hosted SearXNG
(autodetected by tools/web_tools.py) or CAMOFOX_URL (never-configured autodetect in
_resolve_browser_feature_state) is a working setup even with no stored selection, so it must
not be classified "unconfigured" and pre-checked (#92647). OpenAI Whisper shares the audio key
with TTS via resolve_openai_audio_api_key(), so it counts for both tts and stt.
"""
fal_direct = fal_key_is_configured()
audio_direct = bool(resolve_openai_audio_api_key())
return {
"web": _any_env(
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY",
"TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL",
),
"image_gen": fal_direct,
"video_gen": fal_direct,
"tts": audio_direct or _any_env("ELEVENLABS_API_KEY"),
"stt": audio_direct or _any_env("GROQ_API_KEY", "MISTRAL_API_KEY"),
"browser": (
_any_env("BROWSER_USE_API_KEY", "CAMOFOX_URL")
or bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
),
}
def get_gateway_eligible_tools(
config: Optional[Dict[str, object]] = None,
*,
force_fresh: bool = False,
) -> tuple[list[str], list[str], list[str], list[str]]:
"""Return (unconfigured, has_direct, explicit_configured, already_managed) tool key lists.
- unconfigured: tools with no direct credentials and no explicit non-nous selection (easy
switch, safe to pre-check) - has_direct: tools where the user has their own API keys -
explicit_configured: tools with an explicit non-nous selection stored (e.g.
"""
# Fetch entitlement once: it gates the offer (paid access OR a live free tool
# pool) AND tells us which categories are covered (the pool funds image but
# not video, etc.). Fails closed on any error.
account_info = _account_info_or_none(force_fresh=force_fresh)
if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled):
return [], [], [], []
if config is None:
config = load_config() or {}
# Quick provider check without the heavy get_nous_subscription_features call
model_cfg = config.get("model")
if not isinstance(model_cfg, dict) or str(model_cfg.get("provider") or "").strip().lower() != "nous":
return [], [], [], []
direct = _get_gateway_direct_credentials()
# Buckets: already_managed = use_gateway explicitly set (distinct from
# managed_by_nous, which fires implicitly when no direct keys exist);
# explicit_configured = an explicit non-nous selection (e.g. a keyless local
# backend like SearXNG or Camofox) configured on purpose even though it has
# no direct credentials to detect.
buckets: Dict[str, list[str]] = {
"unconfigured": [], "has_direct": [], "explicit_configured": [], "already_managed": [],
}
for key in _ALL_GATEWAY_KEYS:
# Only offer tools the user's entitlement actually covers. For a free
# tool pool that means image but not video; paid users are covered for
# everything.
if not account_info.tool_gateway_entitled_for(_FEATURES[key].coverage):
continue
section_key, field = _GATEWAY_SECTION_FIELDS[key]
selected = _selected_provider(config.get(section_key), field)
if _uses_gateway(config.get(key)):
bucket = "already_managed"
elif selected is not None and selected != "nous":
bucket = "explicit_configured"
elif direct.get(key):
bucket = "has_direct"
else:
bucket = "unconfigured"
buckets[bucket].append(key)
return (
buckets["unconfigured"], buckets["has_direct"],
buckets["explicit_configured"], buckets["already_managed"],
)
def apply_gateway_defaults(
config: Dict[str, object],
tool_keys: list[str],
) -> set[str]:
"""Apply Tool Gateway config for the given tool keys.
Sets ``use_gateway: true`` in each tool's section so the runtime prefers the gateway even when
direct API keys are present. Returns the set of tools actually changed.
"""
changed: set[str] = set()
for key in ("web", "tts", "stt", "browser"):
_ensure_section(config, key)
for key in _ALL_GATEWAY_KEYS:
if key in tool_keys:
_select_nous(config, key)
changed.add(key)
return changed
def prompt_enable_tool_gateway(
config: Dict[str, object],
*,
force_fresh: bool = True,
) -> set[str]:
"""If eligible tools exist, prompt the user (per tool) to enable the Tool Gateway.
"Pool enabled" is the trigger: a user with a live free tool pool (or paid access) is shown a
per-tool checklist of the covered managed backends and picks which to route through the gateway.
"""
# explicit_configured tools (e.g. an explicit `web.backend: searxng`) are
# configured on purpose and are never offered here — same treatment as
# already_managed, just for a non-nous vendor.
unconfigured, has_direct, _explicit_configured, already_managed = get_gateway_eligible_tools(
config,
force_fresh=force_fresh,
)
if not unconfigured and not has_direct:
return set()
try:
from hermes_cli.setup import prompt_checklist
except Exception:
return set()
# Frame the offer by entitlement: a $0 free-tool-pool user is not on a paid
# plan, so don't call it "your subscription".
account_info = _account_info_or_none(force_fresh=False)
pool_only = bool(
account_info
and account_info.paid_service_access is not True
and account_info.tool_access is not None
and account_info.tool_access.enabled
)
source_label = "free tool pool" if pool_only else "Nous subscription"
# Per-tool checklist: unconfigured tools first (pre-checked for new users),
# then tools where the user already has their own key (left unchecked so we
# don't override their own setup unless they ask).
#
# Decline persistence (#92647): tools the user has previously seen offered
# and left unchecked are recorded in ``tool_gateway_declined_tools`` and
# are never pre-checked again — the offer downgrades to opt-in-only.
# Acceptance used to be sticky while refusal was not, so the identical
# pre-checked checklist re-fired on every Nous model swap.
declined_raw = config.get("tool_gateway_declined_tools")
declined: set[str] = (
{str(k) for k in declined_raw} if isinstance(declined_raw, list) else set()
)
offer_keys: list[str] = list(unconfigured) + list(has_direct)
labels: list[str] = [_GATEWAY_TOOL_LABELS[k] for k in unconfigured]
labels += [
f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_FEATURES[k].direct_label}"
for k in has_direct
]
pre_selected = [
i for i, k in enumerate(unconfigured) if k not in declined
]
title = (
"Your free Nous tool pool — pick the tools to enable:"
if pool_only
else "Your Nous subscription includes the Tool Gateway — pick the tools to enable:"
)
try:
chosen_idx = prompt_checklist(title, labels, pre_selected)
except (KeyboardInterrupt, EOFError, OSError, SystemExit):
return set()
chosen_keys = [offer_keys[i] for i in chosen_idx if 0 <= i < len(offer_keys)]
# Persist per-tool declines: every unconfigured tool that was offered and
# NOT chosen was actively left (or unchecked) by the user — remember that
# so the next Nous model swap doesn't pre-check it again. Cancel paths
# (Ctrl-C/ESC above) return before this and record nothing. Choosing a
# previously-declined tool clears its decline.
newly_declined = [k for k in unconfigured if k not in chosen_keys and k not in declined]
undeclined = declined & set(chosen_keys)
if newly_declined or undeclined:
config["tool_gateway_declined_tools"] = sorted(
(declined | set(newly_declined)) - set(chosen_keys)
)
if not chosen_keys:
changed: set[str] = set()
else:
changed = apply_gateway_defaults(config, chosen_keys)
if changed or newly_declined:
from hermes_cli.config import save_config
save_config(config)
for key in sorted(changed):
label = _GATEWAY_TOOL_LABELS.get(key, key)
print(f" ✓ {label}: enabled via {source_label}")
return changed
# ---------------------------------------------------------------------------
# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`)
# ---------------------------------------------------------------------------
def ensure_nous_portal_access(
*,
capability: str = "the Nous Tool Gateway",
coverage_category: Optional[str] = None,
) -> bool:
"""Make sure the user is entitled to the Nous Tool Gateway, logging in if needed.
It only performs the Nous Portal device-code OAuth (when the user isn't already logged in) and
refreshes entitlement, so the caller can enable the single tool the user picked.
Entitlement is satisfied by paid service access OR a live free tool pool. When
``coverage_category`` is given (e.g. ``"fal"`` for image gen), the pool must cover that category
specifically — so a pool user selecting video (``"fal-video"``, not pool-funded) is correctly
denied.
"""
def _entitled(account) -> bool:
if account is None:
return False
if coverage_category is not None:
return account.tool_gateway_entitled_for(coverage_category)
return account.tool_gateway_entitled
# Fast path: already entitled.
info = _account_info_or_none(force_fresh=True)
if _entitled(info):
return True
# If not logged in at all, run the device-code login (auth only).
if info is None or not info.logged_in:
if not _run_nous_portal_login_only(capability=capability):
return False
info = _account_info_or_none(force_fresh=True)
if _entitled(info):
return True
# Logged in but not entitled for this capability — surface neutral billing
# guidance, do not enable. coverage_category keeps a pool user who lacks this
# one category from being told their credits are exhausted.
message = format_nous_portal_entitlement_message(
info, capability=capability, coverage_category=coverage_category
)
if message:
for line in message.splitlines():
print(f" {line}")
return False
def _run_nous_portal_login_only(*, capability: str) -> bool:
"""Run the Nous Portal device-code OAuth and persist credentials only.
No model selection, no provider switch, no Tool Gateway bulk prompt. Returns ``True`` on a
successful login, ``False`` if the user declined or the flow failed.
"""
try:
import hermes_cli.auth as auth
except Exception as exc: # pragma: no cover - defensive
print(f" Could not start Nous Portal login: {exc}")
return False
print()
print(f" {capability} requires a Nous Portal login.")
try:
proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
if proceed not in {"", "y", "yes"}:
print(" Skipped Nous Portal login.")
return False
try:
# Snapshot the active_provider so a tool-config login never silently
# switches the user's inference provider to Nous.
with auth._auth_store_lock():
prior_active_provider = auth._load_auth_store().get("active_provider")
auth_state = None
if auth._read_shared_nous_state():
try:
do_import = input(
" Found existing Nous OAuth credentials. Import them? [Y/n]: "
).strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in {"", "y", "yes"}:
auth_state = auth._try_import_shared_nous_state(timeout_seconds=15.0)
if auth_state is None:
auth_state = auth._nous_device_code_login()
with auth._auth_store_lock():
auth_store = auth._load_auth_store()
auth._save_provider_state(auth_store, "nous", auth_state)
# Preserve the user's existing inference provider — this login is
# for tool entitlement only, not a provider switch.
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
auth._save_auth_store(auth_store)
auth._write_shared_nous_state(auth_state)
auth._sync_nous_pool_from_auth_store()
print(" Nous Portal login successful.")
return True
except KeyboardInterrupt:
print("\n Login cancelled.")
return False
except SystemExit:
# _nous_device_code_login raises SystemExit on subscription_required;
# it already printed billing guidance.
return False
except Exception as exc:
print(f" Nous Portal login failed: {exc}")
return False