feat(tools): surface the free tool pool in entitlement + setup (#36153)
Read the Portal's tool_access claim (JWT + /api/oauth/account) into NousToolAccessInfo and gate managed Tool Gateway access on it: tool_gateway_entitled (paid OR live pool) and per-category tool_gateway_entitled_for(). The pool funds web/image/tts/browser but not video, so per-backend availability, the charge picker (ensure_nous_portal_access coverage_category), and managed defaults all respect coverage. Setup: rebuild prompt_enable_tool_gateway as a per-tool checklist that renders whenever the pool is enabled, lists only pool-covered tools (video excluded for free-pool users), and is framed as the free tool pool for $0 subscribers rather than a paid subscription. get_gateway_eligible_tools now gates and filters off the entitlement snapshot.
This commit is contained in:
@ -7,13 +7,27 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
|
||||
|
||||
# Free tool-pool coverage categories. Kept byte-for-byte aligned with the
|
||||
# Portal's TOOL_COVERAGE_CATEGORIES (nous-account-service
|
||||
# src/server/tool-pool-eligibility.ts). The Portal mints these into the
|
||||
# `tool_access.coverage` map on the JWT and /api/oauth/account; FAL video gen
|
||||
# (`fal-video`) is intentionally excluded from the pool.
|
||||
TOOL_COVERAGE_CATEGORIES = (
|
||||
"firecrawl",
|
||||
"fal",
|
||||
"fal-video",
|
||||
"openai-audio",
|
||||
"browser-use",
|
||||
"modal",
|
||||
)
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
@ -45,6 +59,19 @@ class NousPaidServiceAccessInfo:
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousToolAccessInfo:
|
||||
"""Free tool-pool entitlement, decoupled from paid/billing access.
|
||||
|
||||
Mirrors the Portal's ``tool_access`` claim/field: ``enabled`` is true when a
|
||||
positive tool-pool balance is live and not gated off; ``coverage`` maps each
|
||||
tool category to whether the pool funds it (FAL video is excluded).
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
coverage: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalAccountInfo:
|
||||
logged_in: bool
|
||||
@ -65,6 +92,7 @@ class NousPortalAccountInfo:
|
||||
subscription: Optional[NousPortalSubscriptionInfo] = None
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
tool_access: Optional[NousToolAccessInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
@ -79,7 +107,21 @@ class NousPortalAccountInfo:
|
||||
|
||||
@property
|
||||
def tool_gateway_entitled(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
"""Coarse "entitled to any managed tool" gate: paid access OR a live
|
||||
free tool pool. Use :meth:`tool_gateway_entitled_for` to gate a specific
|
||||
tool category (the pool does not cover every category)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
return self.tool_access is not None and self.tool_access.enabled
|
||||
|
||||
def tool_gateway_entitled_for(self, category: str) -> bool:
|
||||
"""Whether a specific tool category is entitled. Paid users are entitled
|
||||
everywhere; free tool-pool users only where ``coverage[category]`` is
|
||||
true (e.g. image but not video)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
ta = self.tool_access
|
||||
return bool(ta and ta.enabled and ta.coverage.get(category) is True)
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
@ -102,19 +144,38 @@ def format_nous_portal_entitlement_message(
|
||||
*,
|
||||
capability: str = "this feature",
|
||||
include_refresh_hint: bool = True,
|
||||
coverage_category: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return user-facing guidance for a missing Nous paid entitlement.
|
||||
"""Return user-facing guidance for a missing Nous tool-gateway entitlement.
|
||||
|
||||
``None`` means the account is known to have paid service access. The
|
||||
message intentionally works from normalized entitlement fields rather than
|
||||
subscription price alone: purchased credits without a subscription still
|
||||
count as paid access, while a paid subscription with exhausted usable
|
||||
credits does not.
|
||||
``None`` means the account is entitled to use the capability — via paid
|
||||
service access OR a live free tool pool that covers it. The message works
|
||||
from normalized entitlement fields rather than subscription price alone:
|
||||
purchased credits without a subscription still count as paid access, while a
|
||||
paid subscription with exhausted usable credits does not.
|
||||
|
||||
``coverage_category`` scopes the check to a single tool category (e.g.
|
||||
``"fal-video"``). When given, a user who is entitled overall but whose
|
||||
access does not fund that category gets a neutral billing nudge instead of a
|
||||
message implying their credits are exhausted. The pool-vs-paid distinction is
|
||||
never surfaced to the user.
|
||||
"""
|
||||
billing_url = nous_portal_billing_url(account_info)
|
||||
|
||||
if account_info is not None and account_info.paid_service_access is True:
|
||||
return None
|
||||
if account_info is not None:
|
||||
if coverage_category is not None:
|
||||
if account_info.tool_gateway_entitled_for(coverage_category):
|
||||
return None
|
||||
if account_info.tool_gateway_entitled:
|
||||
# Entitled overall (e.g. via the managed tool pool), but this
|
||||
# specific capability isn't covered. Surface a neutral billing
|
||||
# nudge without exposing pool-vs-paid internals to the user.
|
||||
return (
|
||||
f"{capability} isn't included with your current Nous Portal "
|
||||
f"access. Add credits or a subscription to enable it at {billing_url}."
|
||||
)
|
||||
elif account_info.tool_gateway_entitled:
|
||||
return None
|
||||
|
||||
if account_info is None:
|
||||
return (
|
||||
@ -534,6 +595,7 @@ def _info_from_valid_jwt(
|
||||
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access_info,
|
||||
tool_access=_tool_access_from_value(claims.get("tool_access")),
|
||||
raw_claims=dict(claims),
|
||||
)
|
||||
|
||||
@ -571,10 +633,28 @@ def _info_from_account_payload(
|
||||
subscription=subscription,
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access,
|
||||
tool_access=_tool_access_from_value(payload.get("tool_access")),
|
||||
raw_account=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]:
|
||||
"""Parse a Portal ``tool_access`` object (from the JWT claim or the account
|
||||
API) into :class:`NousToolAccessInfo`. Fails closed: a non-object value
|
||||
yields ``None``, and only literal ``true`` counts for ``enabled`` and each
|
||||
coverage entry."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
enabled = _coerce_bool(value.get("enabled")) is True
|
||||
raw_coverage = value.get("coverage")
|
||||
coverage: dict[str, bool] = {}
|
||||
if isinstance(raw_coverage, dict):
|
||||
for key, val in raw_coverage.items():
|
||||
if isinstance(key, str):
|
||||
coverage[key] = val is True
|
||||
return NousToolAccessInfo(enabled=enabled, coverage=coverage)
|
||||
|
||||
|
||||
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
@ -29,6 +29,20 @@ _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",
|
||||
"browser": "browser-use",
|
||||
"modal": "modal",
|
||||
}
|
||||
|
||||
|
||||
def _uses_gateway(section: object) -> bool:
|
||||
"""Return True when a config section explicitly opts into the gateway."""
|
||||
@ -253,12 +267,18 @@ def get_nous_subscription_features(
|
||||
except Exception:
|
||||
account_info = None
|
||||
|
||||
# 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.paid_service_access is True
|
||||
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")
|
||||
@ -332,13 +352,45 @@ 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")
|
||||
# Video gen uses the same fal-queue gateway as image gen.
|
||||
managed_video_available = managed_image_available
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
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_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")
|
||||
)
|
||||
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,
|
||||
@ -543,7 +595,7 @@ def apply_nous_managed_defaults(
|
||||
if not (
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
and features.account_info.tool_gateway_entitled
|
||||
):
|
||||
return set()
|
||||
if not features.provider_is_nous:
|
||||
@ -598,7 +650,13 @@ def apply_nous_managed_defaults(
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
# 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 = {}
|
||||
@ -672,11 +730,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 force_fresh:
|
||||
managed_enabled = managed_nous_tools_enabled(force_fresh=True)
|
||||
else:
|
||||
managed_enabled = managed_nous_tools_enabled()
|
||||
if not managed_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:
|
||||
@ -705,6 +766,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):
|
||||
@ -782,10 +850,14 @@ def prompt_enable_tool_gateway(
|
||||
*,
|
||||
force_fresh: bool = True,
|
||||
) -> set[str]:
|
||||
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
|
||||
"""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.
|
||||
@ -798,93 +870,60 @@ def prompt_enable_tool_gateway(
|
||||
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, 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
|
||||
|
||||
|
||||
@ -893,8 +932,13 @@ def prompt_enable_tool_gateway(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> bool:
|
||||
"""Make sure the user has paid Nous Portal access, logging in if needed.
|
||||
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
|
||||
@ -908,15 +952,28 @@ def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> b
|
||||
already logged in) and refreshes entitlement, so the caller can enable the
|
||||
single tool the user picked.
|
||||
|
||||
Returns ``True`` when the account has paid service access after the flow,
|
||||
``False`` otherwise (declined login, login failed, or no paid entitlement).
|
||||
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 info is not None and info.paid_service_access is True:
|
||||
if _entitled(info):
|
||||
return True
|
||||
|
||||
# If not logged in at all, run the device-code login (auth only).
|
||||
@ -928,11 +985,15 @@ def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> b
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
if info is not None and info.paid_service_access is True:
|
||||
if _entitled(info):
|
||||
return True
|
||||
|
||||
# Logged in but no paid access — surface billing guidance, do not enable.
|
||||
message = format_nous_portal_entitlement_message(info, capability=capability)
|
||||
# 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}")
|
||||
|
||||
@ -1926,6 +1926,18 @@ def _visible_providers(
|
||||
*activates* the gateway once paid access is confirmed.
|
||||
"""
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
acct = features.account_info
|
||||
# Pool-only users (entitled to managed tools via the free tool pool but with
|
||||
# no paid access) get image gen but NOT video gen — the pool doesn't fund
|
||||
# `fal-video`. Rather than advertise a managed video row that would be denied
|
||||
# on select, hide it for them. Logged-out users still see it (advertising)
|
||||
# and paid users are entitled to it.
|
||||
pool_only = bool(
|
||||
acct
|
||||
and acct.logged_in
|
||||
and acct.paid_service_access is not True
|
||||
and acct.tool_gateway_entitled
|
||||
)
|
||||
visible = []
|
||||
for provider in cat.get("providers", []):
|
||||
# Nous-managed Tool Gateway rows stay visible regardless of auth —
|
||||
@ -1938,6 +1950,14 @@ def _visible_providers(
|
||||
and not features.nous_auth_present
|
||||
):
|
||||
continue
|
||||
# Hide the managed video-gen row from pool-only users — their free tool
|
||||
# pool doesn't cover video, so showing it would only lead to a denial.
|
||||
if (
|
||||
pool_only
|
||||
and provider.get("managed_nous_feature") == "video_gen"
|
||||
and not (acct and acct.tool_gateway_entitled_for("fal-video"))
|
||||
):
|
||||
continue
|
||||
visible.append(provider)
|
||||
|
||||
# Inject plugin-registered image_gen backends (OpenAI today, more
|
||||
@ -2711,13 +2731,17 @@ def _configure_provider(
|
||||
# auth + entitlement only, no inference-provider switch and no bulk
|
||||
# "enable all tools" prompt (that lives in `hermes model`).
|
||||
if managed_feature:
|
||||
from hermes_cli.nous_subscription import ensure_nous_portal_access
|
||||
from hermes_cli.nous_subscription import (
|
||||
MANAGED_FEATURE_COVERAGE_CATEGORY,
|
||||
ensure_nous_portal_access,
|
||||
)
|
||||
|
||||
if not ensure_nous_portal_access(
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}",
|
||||
coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature),
|
||||
):
|
||||
_print_warning(
|
||||
" Not enabled — Nous Portal paid access is required for this backend."
|
||||
" Not enabled — Nous Portal access is required for this backend."
|
||||
)
|
||||
return
|
||||
|
||||
@ -3075,13 +3099,17 @@ def _reconfigure_provider(
|
||||
# Same inline Nous Portal login + entitlement gate as _configure_provider:
|
||||
# managed Tool Gateway backends only activate with paid Portal access.
|
||||
if managed_feature:
|
||||
from hermes_cli.nous_subscription import ensure_nous_portal_access
|
||||
from hermes_cli.nous_subscription import (
|
||||
MANAGED_FEATURE_COVERAGE_CATEGORY,
|
||||
ensure_nous_portal_access,
|
||||
)
|
||||
|
||||
if not ensure_nous_portal_access(
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}",
|
||||
coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature),
|
||||
):
|
||||
_print_warning(
|
||||
" Not enabled — Nous Portal paid access is required for this backend."
|
||||
" Not enabled — Nous Portal access is required for this backend."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@ -309,10 +309,28 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_
|
||||
|
||||
|
||||
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo
|
||||
|
||||
# Entitled account (paid → all tools eligible) drives the offer; the prompt
|
||||
# is a per-tool checklist now, so capture the call rather than scrape stdout.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.managed_nous_tools_enabled",
|
||||
lambda *args, **kwargs: True,
|
||||
"hermes_cli.nous_subscription.get_nous_portal_account_info",
|
||||
lambda **kwargs: NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="account_api",
|
||||
fresh=True,
|
||||
paid_service_access=True,
|
||||
),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def _fake_checklist(title, items, pre_selected=None):
|
||||
captured["title"] = title
|
||||
captured["items"] = list(items)
|
||||
return [] # decline; we only assert the prompt was offered
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_checklist", _fake_checklist, raising=False)
|
||||
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "edge"},
|
||||
@ -338,10 +356,9 @@ def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatc
|
||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Tool Gateway prompt should be shown (input() raises OSError in pytest
|
||||
# which is caught, so the prompt text appears but nothing is applied)
|
||||
assert "Tool Gateway" in out
|
||||
# The per-tool Tool Gateway checklist was offered.
|
||||
assert "title" in captured
|
||||
assert "Tool Gateway" in captured["title"] or "tool pool" in captured["title"].lower()
|
||||
|
||||
|
||||
def test_codex_provider_uses_config_model(monkeypatch):
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
"""Tests for Nous subscription feature detection."""
|
||||
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo
|
||||
from hermes_cli import nous_subscription as ns
|
||||
|
||||
|
||||
_POOL_COVERAGE = {
|
||||
"firecrawl": True,
|
||||
"fal": True,
|
||||
"fal-video": False,
|
||||
"openai-audio": True,
|
||||
"browser-use": True,
|
||||
"modal": True,
|
||||
}
|
||||
|
||||
|
||||
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=logged_in,
|
||||
@ -13,6 +23,17 @@ def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountI
|
||||
)
|
||||
|
||||
|
||||
def _pool_account() -> NousPortalAccountInfo:
|
||||
"""A $0 subscriber with a live free tool pool (no paid access)."""
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="jwt",
|
||||
fresh=False,
|
||||
paid_service_access=False,
|
||||
tool_access=NousToolAccessInfo(enabled=True, coverage=_POOL_COVERAGE),
|
||||
)
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
|
||||
env = {"EXA_API_KEY": "exa-test"}
|
||||
|
||||
@ -214,7 +235,10 @@ def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_o
|
||||
|
||||
|
||||
def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
# Paid account: entitled to every category, including video.
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
@ -233,6 +257,117 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
|
||||
|
||||
|
||||
def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch):
|
||||
"""A free-tool-pool user is offered the covered tools but NOT video gen."""
|
||||
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
{"model": {"provider": "nous"}}
|
||||
)
|
||||
|
||||
assert set(unconfigured) == {"web", "image_gen", "tts", "browser"}
|
||||
assert "video_gen" not in unconfigured
|
||||
assert "video_gen" not in has_direct
|
||||
assert "video_gen" not in already_managed
|
||||
|
||||
|
||||
def test_get_gateway_eligible_tools_empty_when_not_entitled(monkeypatch):
|
||||
"""A logged-in free user with no pool and no paid access gets nothing."""
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=False)
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
{"model": {"provider": "nous"}}
|
||||
)
|
||||
|
||||
assert (unconfigured, has_direct, already_managed) == ([], [], [])
|
||||
|
||||
|
||||
def _capture_checklist(monkeypatch, *, selected_idx):
|
||||
"""Patch prompt_checklist to capture its args and return chosen indices."""
|
||||
captured = {}
|
||||
|
||||
def _fake_checklist(title, items, pre_selected=None):
|
||||
captured["title"] = title
|
||||
captured["items"] = list(items)
|
||||
captured["pre_selected"] = list(pre_selected or [])
|
||||
return list(selected_idx)
|
||||
|
||||
import hermes_cli.setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_checklist", _fake_checklist, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.save_config", lambda cfg: None, raising=False
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
|
||||
"""Pool user's checklist lists web/image/tts/browser and never video."""
|
||||
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
captured = _capture_checklist(monkeypatch, selected_idx=[])
|
||||
|
||||
config = {"model": {"provider": "nous"}}
|
||||
ns.prompt_enable_tool_gateway(config)
|
||||
|
||||
blob = " ".join(captured["items"]).lower()
|
||||
assert "firecrawl" in blob # web offered
|
||||
assert "video" not in blob # video NOT offered to a pool user
|
||||
# Pool-aware framing, not "subscription".
|
||||
assert "free" in captured["title"].lower() and "pool" in captured["title"].lower()
|
||||
|
||||
|
||||
def test_prompt_enable_tool_gateway_writes_only_selected(monkeypatch):
|
||||
"""Selecting a subset writes use_gateway only for those tools."""
|
||||
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
# Offered order is _ALL_GATEWAY_KEYS filtered to covered: web, image_gen, tts, browser.
|
||||
# Select index 0 (web) and 1 (image_gen) only.
|
||||
_capture_checklist(monkeypatch, selected_idx=[0, 1])
|
||||
|
||||
config = {"model": {"provider": "nous"}}
|
||||
changed = ns.prompt_enable_tool_gateway(config)
|
||||
|
||||
assert changed == {"web", "image_gen"}
|
||||
assert config["web"]["use_gateway"] is True
|
||||
assert config["image_gen"]["use_gateway"] is True
|
||||
assert "tts" not in config or config.get("tts", {}).get("use_gateway") is not True
|
||||
assert "video_gen" not in config
|
||||
|
||||
|
||||
def test_prompt_enable_tool_gateway_paid_user_offers_video(monkeypatch):
|
||||
"""Paid users still get video gen in the offer (regression guard)."""
|
||||
monkeypatch.setattr(
|
||||
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
captured = _capture_checklist(monkeypatch, selected_idx=[])
|
||||
|
||||
ns.prompt_enable_tool_gateway({"model": {"provider": "nous"}})
|
||||
|
||||
blob = " ".join(captured["items"]).lower()
|
||||
assert "video" in blob
|
||||
|
||||
|
||||
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
|
||||
"""apply_nous_managed_defaults must write video_gen.provider and
|
||||
video_gen.use_gateway when a Nous subscriber selects video_gen
|
||||
|
||||
@ -15,12 +15,17 @@ _VALID_MODAL_MODES = {"auto", "direct", "managed"}
|
||||
|
||||
|
||||
def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
|
||||
"""Return True when the user has paid Nous Portal service access.
|
||||
"""Return True when the user is entitled to the Nous Tool Gateway.
|
||||
|
||||
Entitlement is paid Nous Portal service access OR a live free tool pool
|
||||
(``tool_gateway_entitled``). Per-category coverage (the pool funds image but
|
||||
not video, etc.) is narrowed by callers via ``tool_gateway_entitled_for``;
|
||||
this coarse gate only answers "is any managed tool usable at all".
|
||||
|
||||
Tool Gateway availability fails closed on unknown/error entitlement. We
|
||||
intentionally catch all exceptions and return False — never block startup.
|
||||
``force_fresh=True`` is for interactive configuration flows that should
|
||||
reflect a just-purchased subscription or credits immediately.
|
||||
reflect a just-purchased subscription, credits, or pool grant immediately.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
@ -31,7 +36,7 @@ def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
|
||||
account_info = get_nous_portal_account_info()
|
||||
if not account_info.logged_in:
|
||||
return False
|
||||
return account_info.paid_service_access is True
|
||||
return account_info.tool_gateway_entitled
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user