Merge commit '6110aed9b' into feat/whatsapp-cloud-api

This commit is contained in:
emozilla
2026-06-10 21:39:22 -04:00
3038 changed files with 499127 additions and 63840 deletions
+421 -97
View File
@@ -6,8 +6,12 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.auth import get_nous_auth_status
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 (
@@ -25,6 +29,23 @@ _DEFAULT_PLATFORM_TOOLSETS = {
"cli": "hermes-cli",
}
# Maps a tools_config provider's ``managed_nous_feature`` to the 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.
MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {
"web": "firecrawl",
"image_gen": "fal",
"video_gen": "fal-video",
"tts": "openai-audio",
# STT shares the TTS coverage category: both ride the managed
# "openai-audio" gateway endpoint (speech + transcriptions).
"stt": "openai-audio",
"browser": "browser-use",
"modal": "modal",
}
def _uses_gateway(section: object) -> bool:
"""Return True when a config section explicitly opts into the gateway."""
@@ -53,6 +74,7 @@ class NousSubscriptionFeatures:
nous_auth_present: bool
provider_is_nous: bool
features: Dict[str, NousFeatureState]
account_info: Optional[NousPortalAccountInfo] = None
@property
def web(self) -> NousFeatureState:
@@ -74,12 +96,16 @@ class NousSubscriptionFeatures:
def browser(self) -> NousFeatureState:
return self.features["browser"]
@property
def video_gen(self) -> NousFeatureState:
return self.features["video_gen"]
@property
def modal(self) -> NousFeatureState:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "stt", "browser", "modal")
ordered = ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
for key in ordered:
yield self.features[key]
@@ -140,6 +166,33 @@ def _has_agent_browser() -> bool:
return bool(agent_browser_bin or local_bin.exists())
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. Cloud providers (Browserbase, Browser Use, Firecrawl) host
their own Chromium and therefore gate on :func:`_has_agent_browser` alone.
"""
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
if _using_lightpanda_engine():
return True
return _chromium_installed()
def _browser_label(current_provider: str) -> str:
mapping = {
"browserbase": "Browserbase",
@@ -179,13 +232,23 @@ def _resolve_browser_feature_state(
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."""
"""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_local_runnable`` additionally requires a usable local Chromium
build (or the Lightpanda engine), mirroring the local-mode tail of
:func:`tools.browser_tool.check_browser_requirements`. Local mode must gate
on the latter, or setup/status advertise a browser that fails on first use
when Chromium is missing.
"""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
@@ -214,7 +277,7 @@ def _resolve_browser_feature_state(
return current_provider, False, False, False
current_provider = "local"
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
@@ -234,13 +297,15 @@ def _resolve_browser_feature_state(
active = bool(browser_tool_enabled and available)
return "browserbase", available, active, False
available = bool(browser_local_available)
available = bool(browser_local_runnable)
active = bool(browser_tool_enabled and available)
return "local", available, active, False
def get_nous_subscription_features(
config: Optional[Dict[str, object]] = None,
*,
force_fresh: bool = False,
) -> NousSubscriptionFeatures:
if config is None:
config = load_config() or {}
@@ -249,16 +314,30 @@ def get_nous_subscription_features(
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
try:
nous_status = get_nous_auth_status()
if force_fresh:
account_info = get_nous_portal_account_info(force_fresh=True)
else:
account_info = get_nous_portal_account_info()
except Exception:
nous_status = {}
account_info = None
managed_tools_flag = managed_nous_tools_enabled()
nous_auth_present = bool(nous_status.get("logged_in"))
# 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)
def _entitled_for(category: str) -> bool:
return bool(account_info and account_info.tool_gateway_entitled_for(category))
subscribed = provider_is_nous or nous_auth_present
web_tool_enabled = _toolset_enabled(config, "web")
image_tool_enabled = _toolset_enabled(config, "image_gen")
video_tool_enabled = _toolset_enabled(config, "video_gen")
tts_tool_enabled = _toolset_enabled(config, "tts")
browser_tool_enabled = _toolset_enabled(config, "browser")
modal_tool_enabled = _toolset_enabled(config, "terminal")
@@ -300,6 +379,8 @@ def get_nous_subscription_features(
browser_use_gateway = _uses_gateway(browser_cfg)
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = _uses_gateway(image_gen_cfg)
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
video_use_gateway = _uses_gateway(video_gen_cfg)
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
@@ -307,6 +388,7 @@ def get_nous_subscription_features(
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
direct_searxng = bool(get_env_value("SEARXNG_URL"))
direct_fal = fal_key_is_configured()
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
direct_openai_tts = bool(resolve_openai_audio_api_key())
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
@@ -338,6 +420,8 @@ def get_nous_subscription_features(
direct_tavily = False
if image_use_gateway:
direct_fal = False
if video_use_gateway:
direct_fal_video = False
if tts_use_gateway:
direct_openai_tts = False
direct_elevenlabs = False
@@ -350,19 +434,54 @@ def get_nous_subscription_features(
direct_browser_use = False
direct_browserbase = False
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_web_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("firecrawl")
and _entitled_for("firecrawl")
)
managed_image_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("fal-queue")
and _entitled_for("fal")
)
# Video gen rides the same fal-queue gateway as image gen, but the free tool
# pool funds image and NOT video — so gate it on its own coverage category
# rather than aliasing it to image. (Paid users are entitled to both.)
managed_video_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("fal-queue")
and _entitled_for("fal-video")
)
managed_tts_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("openai-audio")
and _entitled_for("openai-audio")
)
# STT and TTS share the same managed gateway endpoint ("openai-audio")
# because the OpenAI audio API covers both /audio/speech (TTS) and
# /audio/transcriptions (STT). One probe, used by both.
# /audio/transcriptions (STT). One probe (and one entitlement), used by both.
managed_stt_available = managed_tts_available
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
managed_browser_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("browser-use")
and _entitled_for("browser-use")
)
managed_modal_available = (
managed_tools_flag
and nous_auth_present
and is_managed_tool_gateway_ready("modal")
and _entitled_for("modal")
)
modal_state = resolve_modal_backend_state(
modal_mode,
has_direct=direct_modal,
managed_ready=managed_modal_available,
managed_enabled=managed_tools_flag,
)
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
@@ -392,6 +511,10 @@ def get_nous_subscription_features(
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
image_available = bool(managed_image_available or direct_fal)
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
video_available = bool(managed_video_available or direct_fal_video)
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
@@ -426,6 +549,7 @@ def get_nous_subscription_features(
stt_active = stt_available
browser_local_available = _has_agent_browser()
browser_local_runnable = _local_browser_runnable()
(
browser_current_provider,
browser_available,
@@ -436,6 +560,7 @@ def get_nous_subscription_features(
browser_provider=browser_provider,
browser_provider_explicit=browser_provider_explicit,
browser_local_available=browser_local_available,
browser_local_runnable=browser_local_runnable,
direct_camofox=direct_camofox,
direct_browserbase=direct_browserbase,
direct_browser_use=direct_browser_use,
@@ -511,6 +636,18 @@ def get_nous_subscription_features(
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
),
"video_gen": NousFeatureState(
key="video_gen",
label="Video generation",
included_by_default=False,
available=video_available,
active=video_active,
managed_by_nous=video_managed,
direct_override=video_active and not video_managed,
toolset_enabled=video_tool_enabled,
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
explicit_configured=direct_fal_video,
),
"tts": NousFeatureState(
key="tts",
label="OpenAI TTS",
@@ -569,6 +706,7 @@ def get_nous_subscription_features(
nous_auth_present=nous_auth_present,
provider_is_nous=provider_is_nous,
features=features,
account_info=account_info,
)
@@ -579,11 +717,15 @@ def apply_nous_managed_defaults(
config: Dict[str, object],
*,
enabled_toolsets: Optional[Iterable[str]] = None,
force_fresh: bool = False,
) -> set[str]:
if not managed_nous_tools_enabled():
features = get_nous_subscription_features(config, force_fresh=force_fresh)
if not (
features.account_info
and features.account_info.logged_in
and features.account_info.tool_gateway_entitled
):
return set()
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
@@ -646,8 +788,28 @@ def apply_nous_managed_defaults(
changed.add("browser")
if "image_gen" in selected_toolsets and not fal_key_is_configured():
image_cfg = config.get("image_gen")
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
changed.add("image_gen")
# 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.
if (
"video_gen" in selected_toolsets
and not fal_key_is_configured()
and features.account_info.tool_gateway_entitled_for("fal-video")
):
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
@@ -658,6 +820,7 @@ def apply_nous_managed_defaults(
_GATEWAY_TOOL_LABELS = {
"web": "Web search & extract (Firecrawl)",
"image_gen": "Image generation (FAL)",
"video_gen": "Video generation (FAL)",
"tts": "Text-to-speech (OpenAI TTS)",
"stt": "Speech-to-text (OpenAI Whisper)",
"browser": "Browser automation (Browser Use)",
@@ -666,6 +829,7 @@ _GATEWAY_TOOL_LABELS = {
def _get_gateway_direct_credentials() -> Dict[str, bool]:
"""Return a dict of tool_key -> has_direct_credentials."""
fal_direct = fal_key_is_configured()
return {
"web": bool(
get_env_value("FIRECRAWL_API_KEY")
@@ -674,7 +838,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
or get_env_value("TAVILY_API_KEY")
or get_env_value("EXA_API_KEY")
),
"image_gen": fal_key_is_configured(),
"image_gen": fal_direct,
"video_gen": fal_direct,
"tts": bool(
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
@@ -698,16 +863,19 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
_GATEWAY_DIRECT_LABELS = {
"web": "Firecrawl/Exa/Parallel/Tavily key",
"image_gen": "FAL key",
"video_gen": "FAL key",
"tts": "OpenAI/ElevenLabs key",
"stt": "OpenAI/Groq/Mistral key",
"browser": "Browser Use/Browserbase key",
}
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "stt", "browser")
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser")
def get_gateway_eligible_tools(
config: Optional[Dict[str, object]] = None,
*,
force_fresh: bool = False,
) -> tuple[list[str], list[str], list[str]]:
"""Return (unconfigured, has_direct, already_managed) tool key lists.
@@ -718,7 +886,14 @@ def get_gateway_eligible_tools(
All lists are empty when the user is not a paid Nous subscriber or
is not using Nous as their provider.
"""
if not managed_nous_tools_enabled():
# 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.
try:
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
except Exception:
return [], [], []
if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled):
return [], [], []
if config is None:
@@ -738,6 +913,7 @@ def get_gateway_eligible_tools(
opted_in = {
"web": _uses_gateway(config.get("web")),
"image_gen": _uses_gateway(config.get("image_gen")),
"video_gen": _uses_gateway(config.get("video_gen")),
"tts": _uses_gateway(config.get("tts")),
"stt": _uses_gateway(config.get("stt")),
"browser": _uses_gateway(config.get("browser")),
@@ -747,6 +923,13 @@ def get_gateway_eligible_tools(
has_direct: list[str] = []
already_managed: list[str] = []
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(
MANAGED_FEATURE_COVERAGE_CATEGORY[key]
):
continue
if opted_in.get(key):
already_managed.append(key)
elif direct.get(key):
@@ -817,109 +1000,250 @@ def apply_gateway_defaults(
image_cfg["use_gateway"] = True
changed.add("image_gen")
if "video_gen" in tool_keys:
video_cfg = config.get("video_gen")
if not isinstance(video_cfg, dict):
video_cfg = {}
config["video_gen"] = video_cfg
video_cfg["provider"] = "fal"
video_cfg["use_gateway"] = True
changed.add("video_gen")
return changed
def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
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.
Uses prompt_choice() with a description parameter so the curses TUI
shows the tool context alongside the choices.
"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. The free pool funds web/image/
tts/browser but not video, so the checklist only lists covered tools (the
coverage filter lives in get_gateway_eligible_tools).
Returns the set of tools that were enabled, or empty set if the user
declined or no tools were eligible.
"""
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(config)
unconfigured, has_direct, 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_choice
from hermes_cli.setup import prompt_checklist
except Exception:
return set()
# Build description lines showing full status of all gateway tools
desc_parts: list[str] = [
"",
" The Tool Gateway gives you access to web search, image generation,",
" text-to-speech, speech-to-text, and browser automation through your",
" Nous subscription. No need to sign up for separate API keys — just",
" pick the tools you want.",
"",
# Frame the offer by entitlement: a $0 free-tool-pool user is not on a paid
# plan, so don't call it "your subscription".
try:
account_info = get_nous_portal_account_info(force_fresh=False)
except Exception:
account_info = None
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).
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 {_GATEWAY_DIRECT_LABELS[k]}"
for k in has_direct
]
if already_managed:
for k in already_managed:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using Tool Gateway")
if unconfigured:
for k in unconfigured:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — not configured")
if has_direct:
for k in has_direct:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using {_GATEWAY_DIRECT_LABELS[k]}")
# Build short choice labels — detail is in the description above
choices: list[str] = []
choice_keys: list[str] = [] # maps choice index -> action
if unconfigured and has_direct:
choices.append("Enable for all tools (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Enable only for tools without existing keys")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
elif unconfigured:
choices.append("Enable Tool Gateway")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
pre_selected = list(range(len(unconfigured)))
if pool_only:
title = "Your free Nous tool pool — pick the tools to enable:"
else:
choices.append("Enable Tool Gateway (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Skip")
choice_keys.append("skip")
description = "\n".join(desc_parts) if desc_parts else None
# Default to "Enable" when user has no direct keys (new user),
# default to "Skip" when they have existing keys to preserve.
default_idx = 0 if not has_direct else len(choices) - 1
title = (
"Your Nous subscription includes the Tool Gateway — "
"pick the tools to enable:"
)
try:
idx = prompt_choice(
"Your Nous subscription includes the Tool Gateway.",
choices,
default_idx,
description=description,
)
chosen_idx = prompt_checklist(title, labels, pre_selected)
except (KeyboardInterrupt, EOFError, OSError, SystemExit):
return set()
action = choice_keys[idx]
if action == "skip":
chosen_keys = [offer_keys[i] for i in chosen_idx if 0 <= i < len(offer_keys)]
if not chosen_keys:
return set()
if action == "all":
# Apply to switchable tools + ensure already-managed tools also
# have use_gateway persisted in config for consistency.
to_apply = list(_ALL_GATEWAY_KEYS)
else:
to_apply = unconfigured
changed = apply_gateway_defaults(config, to_apply)
changed = apply_gateway_defaults(config, chosen_keys)
if changed:
from hermes_cli.config import save_config
save_config(config)
# Only report the tools that actually switched (not already-managed ones)
newly_switched = changed - set(already_managed)
for key in sorted(newly_switched):
for key in sorted(changed):
label = _GATEWAY_TOOL_LABELS.get(key, key)
print(f"{label}: enabled via Nous subscription")
if already_managed and not newly_switched:
print(" (all tools already using Tool Gateway)")
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.
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
login, this:
- does NOT change the inference provider (``model.provider`` is untouched),
- does NOT run model selection, and
- does NOT offer the bulk "enable for all tools" Tool Gateway prompt.
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.
Returns ``True`` when the account is entitled after the flow, ``False``
otherwise (declined login, login failed, or no entitlement).
"""
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.
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
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
try:
info = get_nous_portal_account_info(force_fresh=True)
except Exception:
info = None
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:
from hermes_cli.auth import (
_auth_store_lock,
_load_auth_store,
_nous_device_code_login,
_read_shared_nous_state,
_save_auth_store,
_save_provider_state,
_sync_nous_pool_from_auth_store,
_try_import_shared_nous_state,
_write_shared_nous_state,
)
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_store_lock():
prior_active_provider = _load_auth_store().get("active_provider")
auth_state = None
shared = _read_shared_nous_state()
if shared:
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 = _try_import_shared_nous_state(timeout_seconds=15.0)
if auth_state is None:
auth_state = _nous_device_code_login()
with _auth_store_lock():
auth_store = _load_auth_store()
_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)
_save_auth_store(auth_store)
_write_shared_nous_state(auth_state)
_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