Merge commit '6110aed9b' into feat/whatsapp-cloud-api
This commit is contained in:
+421
-97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user