mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(auth) normalise the way in which we check whether a user has free/paid access to nous portal so we can expose behaviour and error messages accordingly.
This commit is contained in:
parent
0bf9b867cf
commit
406901b27d
32 changed files with 2470 additions and 181 deletions
|
|
@ -802,16 +802,18 @@ def format_auth_error(error: Exception) -> str:
|
|||
return f"{error} Run `hermes model` to re-authenticate."
|
||||
|
||||
if error.code == "subscription_required":
|
||||
return (
|
||||
"No active paid subscription found on Nous Portal. "
|
||||
"Please purchase/activate a subscription, then retry."
|
||||
)
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
return "No active paid subscription found. Please purchase/activate a subscription, then retry."
|
||||
|
||||
if error.code == "insufficient_credits":
|
||||
return (
|
||||
"Subscription credits are exhausted. "
|
||||
"Top up/renew credits in Nous Portal, then retry."
|
||||
)
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
return "Subscription credits are exhausted. Top up/renew credits, then retry."
|
||||
|
||||
if error.code in {"subscription_expired", "no_usable_credits", "account_missing"}:
|
||||
if error.provider == "nous":
|
||||
return _format_nous_entitlement_auth_error(error)
|
||||
|
||||
if error.code == "temporarily_unavailable":
|
||||
return f"{error} Please retry in a few seconds."
|
||||
|
|
@ -819,6 +821,25 @@ def format_auth_error(error: Exception) -> str:
|
|||
return str(error)
|
||||
|
||||
|
||||
def _format_nous_entitlement_auth_error(error: AuthError) -> str:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
message = format_nous_portal_entitlement_message(
|
||||
account_info,
|
||||
capability="Nous model access",
|
||||
)
|
||||
if message:
|
||||
return message
|
||||
except Exception:
|
||||
pass
|
||||
return f"{error} Check credits or billing in Nous Portal, then retry."
|
||||
|
||||
|
||||
def _token_fingerprint(token: Any) -> Optional[str]:
|
||||
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
|
||||
if not isinstance(token, str):
|
||||
|
|
@ -5627,6 +5648,8 @@ def _empty_nous_auth_status() -> Dict[str, Any]:
|
|||
"access_expires_at": None,
|
||||
"agent_key_expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
"inference_credential_present": False,
|
||||
"credential_source": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -5655,24 +5678,36 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
|
|||
return (agent_exp, access_exp, -priority)
|
||||
|
||||
entry = max(entries, key=_entry_sort_key)
|
||||
access_token = (
|
||||
getattr(entry, "access_token", None)
|
||||
or getattr(entry, "runtime_api_key", "")
|
||||
)
|
||||
if not access_token:
|
||||
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
if not runtime_key:
|
||||
return _empty_nous_auth_status()
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
|
||||
refresh_token = getattr(entry, "refresh_token", None)
|
||||
is_portal_oauth = bool(access_token) and (
|
||||
auth_type.startswith("oauth") or bool(refresh_token)
|
||||
)
|
||||
label = getattr(entry, "label", "unknown")
|
||||
portal_status_url = None
|
||||
if is_portal_oauth:
|
||||
portal_status_url = (
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or DEFAULT_NOUS_PORTAL_URL
|
||||
)
|
||||
|
||||
return {
|
||||
"logged_in": True,
|
||||
"portal_base_url": getattr(entry, "portal_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"logged_in": is_portal_oauth,
|
||||
"portal_base_url": portal_status_url,
|
||||
"inference_base_url": getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None),
|
||||
"access_token": access_token,
|
||||
"access_token": access_token if is_portal_oauth else None,
|
||||
"access_expires_at": getattr(entry, "expires_at", None),
|
||||
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||
"has_refresh_token": bool(getattr(entry, "refresh_token", None)),
|
||||
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
"has_refresh_token": bool(refresh_token),
|
||||
"inference_credential_present": True,
|
||||
"credential_source": f"pool:{label}",
|
||||
"source": f"pool:{label}",
|
||||
}
|
||||
except Exception:
|
||||
return _empty_nous_auth_status()
|
||||
|
|
@ -5755,6 +5790,10 @@ def _compute_nous_auth_status() -> Dict[str, Any]:
|
|||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||
"has_refresh_token": bool(state.get("refresh_token")),
|
||||
"access_token": state.get("access_token"),
|
||||
"inference_credential_present": bool(
|
||||
state.get("access_token") or state.get("agent_key")
|
||||
),
|
||||
"credential_source": "auth_store",
|
||||
"source": "auth_store",
|
||||
}
|
||||
try:
|
||||
|
|
@ -5772,6 +5811,8 @@ def _compute_nous_auth_status() -> Dict[str, Any]:
|
|||
or refreshed_state.get("agent_key_expires_at")
|
||||
or base_status.get("agent_key_expires_at"),
|
||||
"has_refresh_token": bool(refreshed_state.get("refresh_token")),
|
||||
"inference_credential_present": True,
|
||||
"credential_source": "auth_store",
|
||||
"source": f"runtime:{creds.get('source', 'portal')}",
|
||||
"key_id": creds.get("key_id"),
|
||||
}
|
||||
|
|
@ -6283,6 +6324,7 @@ def _prompt_model_selection(
|
|||
pricing: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
unavailable_models: Optional[List[str]] = None,
|
||||
portal_url: str = "",
|
||||
unavailable_message: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
|
||||
|
||||
|
|
@ -6374,18 +6416,22 @@ def _prompt_model_selection(
|
|||
choices.append(" Enter custom model name")
|
||||
choices.append(" Skip (keep current)")
|
||||
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip()
|
||||
if not unavailable_footer and _unavailable:
|
||||
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
|
||||
|
||||
# Print the unavailable block BEFORE the menu via regular print().
|
||||
# simple_term_menu pads title lines to terminal width (causes wrapping),
|
||||
# so we keep the title minimal and use stdout for the static block.
|
||||
# clear_screen=False means our printed output stays visible above.
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
if _unavailable:
|
||||
print(menu_title)
|
||||
print()
|
||||
for mid in _unavailable:
|
||||
print(f"{_DIM} {_label(mid)}{_RESET}")
|
||||
print()
|
||||
print(f"{_DIM} ── Upgrade at {_upgrade_url} for paid models ──{_RESET}")
|
||||
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
|
||||
print()
|
||||
effective_title = "Available free models:"
|
||||
else:
|
||||
|
|
@ -6427,8 +6473,11 @@ def _prompt_model_selection(
|
|||
|
||||
if _unavailable:
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip() or (
|
||||
f"Unavailable models (requires paid tier — upgrade at {_upgrade_url})"
|
||||
)
|
||||
print()
|
||||
print(f" {_DIM}── Unavailable models (requires paid tier — upgrade at {_upgrade_url}) ──{_RESET}")
|
||||
print(f" {_DIM}── {unavailable_footer} ──{_RESET}")
|
||||
for mid in _unavailable:
|
||||
print(f" {'':>{num_width}} {_DIM}{_label(mid)}{_RESET}")
|
||||
print()
|
||||
|
|
@ -7626,8 +7675,9 @@ def _nous_device_code_login(
|
|||
portal_url = auth_state.get(
|
||||
"portal_base_url", DEFAULT_NOUS_PORTAL_URL
|
||||
).rstrip("/")
|
||||
message = format_auth_error(exc)
|
||||
print()
|
||||
print("Your Nous Portal account does not have an active subscription.")
|
||||
print(message)
|
||||
print(f" Subscribe here: {portal_url}/billing")
|
||||
print()
|
||||
print("After subscribing, run `hermes model` again to finish setup.")
|
||||
|
|
@ -7737,11 +7787,30 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||
|
||||
print()
|
||||
unavailable_models: list = []
|
||||
unavailable_message = ""
|
||||
if model_ids:
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
free_tier = check_nous_free_tier()
|
||||
# Force fresh account data for model selection so recent credit
|
||||
# purchases are reflected immediately.
|
||||
free_tier = check_nous_free_tier(force_fresh=True)
|
||||
_portal_for_recs = auth_state.get("portal_base_url", "")
|
||||
if free_tier:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
_account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
unavailable_message = (
|
||||
format_nous_portal_entitlement_message(
|
||||
_account_info,
|
||||
capability="paid Nous models",
|
||||
)
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
unavailable_message = ""
|
||||
# The Portal's freeRecommendedModels endpoint is the
|
||||
# source of truth for what's free *right now*. Augment
|
||||
# the curated list with anything new the Portal flags
|
||||
|
|
@ -7768,11 +7837,12 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||
model_ids, pricing=pricing,
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_portal,
|
||||
unavailable_message=unavailable_message,
|
||||
)
|
||||
elif unavailable_models:
|
||||
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
print("No free models currently available.")
|
||||
print(f"Upgrade at {_url} to access paid models.")
|
||||
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
|
||||
else:
|
||||
print("No curated models available for Nous Portal.")
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -2997,6 +2997,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||
"""Nous Portal provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state,
|
||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
|
|
@ -3092,8 +3093,21 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||
# Fetch live pricing (non-blocking — returns empty dict on failure)
|
||||
pricing = get_pricing_for_provider("nous")
|
||||
|
||||
# Check if user is on free tier
|
||||
free_tier = check_nous_free_tier()
|
||||
# Force fresh account data for model selection so recent credit purchases
|
||||
# are reflected immediately.
|
||||
free_tier = check_nous_free_tier(force_fresh=True)
|
||||
if not free_tier:
|
||||
try:
|
||||
refreshed_creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||
)
|
||||
if refreshed_creds:
|
||||
creds = refreshed_creds
|
||||
except Exception:
|
||||
# Runtime inference has its own paid-entitlement recovery path; do
|
||||
# not block model selection if this opportunistic remint fails.
|
||||
pass
|
||||
|
||||
# Resolve portal URL early — needed both for upgrade links and for the
|
||||
# freeRecommendedModels endpoint below.
|
||||
|
|
@ -3115,7 +3129,24 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||
# newly-launched paid models surface in the picker too — independent
|
||||
# of CLI release cadence.
|
||||
unavailable_models: list[str] = []
|
||||
unavailable_message = ""
|
||||
if free_tier:
|
||||
try:
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
|
||||
_account_info = get_nous_portal_account_info(force_fresh=True)
|
||||
unavailable_message = (
|
||||
format_nous_portal_entitlement_message(
|
||||
_account_info,
|
||||
capability="paid Nous models",
|
||||
)
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
unavailable_message = ""
|
||||
model_ids, pricing = union_with_portal_free_recommendations(
|
||||
model_ids, pricing, _nous_portal_url,
|
||||
)
|
||||
|
|
@ -3137,7 +3168,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
|
||||
|
||||
_url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
print(f"Upgrade at {_url} to access paid models.")
|
||||
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
|
||||
return
|
||||
|
||||
print(
|
||||
|
|
@ -3150,6 +3181,7 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||
pricing=pricing,
|
||||
unavailable_models=unavailable_models,
|
||||
portal_url=_nous_portal_url,
|
||||
unavailable_message=unavailable_message,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
|
|
|
|||
|
|
@ -518,9 +518,19 @@ def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dic
|
|||
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
|
||||
"""Return True if the account info indicates a free (unpaid) tier.
|
||||
|
||||
Checks ``subscription.monthly_charge == 0``. Returns False when
|
||||
the field is missing or unparseable (assumes paid — don't block users).
|
||||
Prefer the Portal's explicit ``paid_service_access.allowed`` entitlement
|
||||
decision. Legacy payloads fall back to ``subscription.monthly_charge == 0``.
|
||||
Returns False when both signals are missing or unparseable.
|
||||
"""
|
||||
paid_access = account_info.get("paid_service_access")
|
||||
if isinstance(paid_access, dict):
|
||||
allowed = paid_access.get("allowed")
|
||||
if isinstance(allowed, bool):
|
||||
return not allowed
|
||||
paid = paid_access.get("paid_access")
|
||||
if isinstance(paid, bool):
|
||||
return not paid
|
||||
|
||||
sub = account_info.get("subscription")
|
||||
if not isinstance(sub, dict):
|
||||
return False
|
||||
|
|
@ -699,40 +709,28 @@ _FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)
|
|||
_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)
|
||||
|
||||
|
||||
def check_nous_free_tier() -> bool:
|
||||
def check_nous_free_tier(*, force_fresh: bool = False) -> bool:
|
||||
"""Check if the current Nous Portal user is on a free (unpaid) tier.
|
||||
|
||||
Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid
|
||||
hitting the Portal API on every call. The cache is short-lived so
|
||||
that an account upgrade is reflected within a few minutes.
|
||||
|
||||
Returns False (assume paid) on any error — never blocks paying users.
|
||||
Returns True only when entitlement is known to be free. Unknown/error
|
||||
states return False so this compatibility wrapper does not block users.
|
||||
"""
|
||||
global _free_tier_cache
|
||||
now = time.monotonic()
|
||||
if _free_tier_cache is not None:
|
||||
if not force_fresh and _free_tier_cache is not None:
|
||||
cached_result, cached_at = _free_tier_cache
|
||||
if now - cached_at < _FREE_TIER_CACHE_TTL:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
# Ensure we have a fresh token (triggers refresh if needed)
|
||||
resolve_nous_runtime_credentials(min_key_ttl_seconds=60)
|
||||
|
||||
state = get_provider_auth_state("nous")
|
||||
if not state:
|
||||
_free_tier_cache = (False, now)
|
||||
return False
|
||||
access_token = state.get("access_token", "")
|
||||
portal_url = state.get("portal_base_url", "")
|
||||
if not access_token:
|
||||
_free_tier_cache = (False, now)
|
||||
return False
|
||||
|
||||
account_info = fetch_nous_account_tier(access_token, portal_url)
|
||||
result = is_nous_free_tier(account_info)
|
||||
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
|
||||
result = account_info.is_free_tier
|
||||
_free_tier_cache = (result, now)
|
||||
return result
|
||||
except Exception:
|
||||
|
|
|
|||
678
hermes_cli/nous_account.py
Normal file
678
hermes_cli/nous_account.py
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
"""Normalized Nous Portal account entitlement helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalSubscriptionInfo:
|
||||
plan: Optional[str] = None
|
||||
tier: Optional[int] = None
|
||||
monthly_charge: Optional[float] = None
|
||||
current_period_end: Optional[str] = None
|
||||
credits_remaining: Optional[float] = None
|
||||
rollover_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPaidServiceAccessInfo:
|
||||
allowed: Optional[bool] = None
|
||||
paid_access: Optional[bool] = None
|
||||
reason: Optional[str] = None
|
||||
organisation_id: Optional[str] = None
|
||||
effective_at_ms: Optional[int] = None
|
||||
has_active_subscription: Optional[bool] = None
|
||||
active_subscription_is_paid: Optional[bool] = None
|
||||
subscription_tier: Optional[int] = None
|
||||
subscription_monthly_charge: Optional[float] = None
|
||||
subscription_credits_remaining: Optional[float] = None
|
||||
purchased_credits_remaining: Optional[float] = None
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalAccountInfo:
|
||||
logged_in: bool
|
||||
source: NousAccountInfoSource
|
||||
fresh: bool
|
||||
user_id: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
product_id: Optional[str] = None
|
||||
nous_client: Optional[str] = None
|
||||
portal_base_url: Optional[str] = None
|
||||
inference_base_url: Optional[str] = None
|
||||
inference_credential_present: bool = False
|
||||
credential_source: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
email: Optional[str] = None
|
||||
privy_did: Optional[str] = None
|
||||
subscription: Optional[NousPortalSubscriptionInfo] = None
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
|
||||
@property
|
||||
def is_free_tier(self) -> bool:
|
||||
return self.paid_service_access is False
|
||||
|
||||
@property
|
||||
def tool_gateway_entitled(self) -> bool:
|
||||
return self.paid_service_access is True
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
"""Return the billing URL for a normalized Nous account snapshot."""
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
|
||||
except Exception:
|
||||
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
|
||||
base = None
|
||||
if account_info is not None:
|
||||
base = account_info.portal_base_url
|
||||
if not isinstance(base, str) or not base.strip():
|
||||
base = DEFAULT_NOUS_PORTAL_URL
|
||||
return f"{base.rstrip('/')}/billing"
|
||||
|
||||
|
||||
def format_nous_portal_entitlement_message(
|
||||
account_info: Optional[NousPortalAccountInfo],
|
||||
*,
|
||||
capability: str = "this feature",
|
||||
include_refresh_hint: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Return user-facing guidance for a missing Nous paid 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.
|
||||
"""
|
||||
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 None:
|
||||
return (
|
||||
f"Hermes could not verify your Nous Portal entitlement, so {capability} "
|
||||
f"is unavailable. Run `hermes model` to refresh your login, or check "
|
||||
f"billing at {billing_url}."
|
||||
)
|
||||
|
||||
if not account_info.logged_in:
|
||||
if account_info.inference_credential_present:
|
||||
return (
|
||||
f"Nous inference credentials are configured, but Hermes cannot verify "
|
||||
f"your Nous Portal paid access for {capability}. Log in with "
|
||||
f"`hermes model` to enable Portal-managed features. Billing and "
|
||||
f"credits are managed at {billing_url}."
|
||||
)
|
||||
return (
|
||||
f"Log in to Nous Portal to use {capability}: run `hermes model`. "
|
||||
f"Billing and credits are managed at {billing_url}."
|
||||
)
|
||||
|
||||
if account_info.paid_service_access is None:
|
||||
detail = (
|
||||
f"Hermes could not verify your Nous Portal paid access, so {capability} "
|
||||
f"is unavailable."
|
||||
)
|
||||
if account_info.error:
|
||||
detail += f" Account lookup failed: {account_info.error}."
|
||||
if include_refresh_hint:
|
||||
detail += " Run `hermes model` to refresh your session."
|
||||
detail += f" Check billing at {billing_url}."
|
||||
return detail
|
||||
|
||||
access = account_info.paid_service_access_info
|
||||
reason = access.reason if access else None
|
||||
if reason == "account_missing":
|
||||
return (
|
||||
f"Hermes could not find a Nous Portal account or organisation for this "
|
||||
f"login, so {capability} is unavailable. Run `hermes model` to "
|
||||
f"authenticate again; if the problem persists, contact Nous support."
|
||||
)
|
||||
|
||||
if reason == "no_usable_credits" or account_info.paid_service_access is False:
|
||||
message = _no_paid_access_message(account_info, capability, billing_url)
|
||||
if include_refresh_hint and not account_info.fresh:
|
||||
message += " If you recently bought credits, run `hermes model` to refresh Hermes."
|
||||
return message
|
||||
|
||||
return (
|
||||
f"Your Nous Portal account does not currently have paid service access, "
|
||||
f"so {capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _no_paid_access_message(
|
||||
account_info: NousPortalAccountInfo,
|
||||
capability: str,
|
||||
billing_url: str,
|
||||
) -> str:
|
||||
access = account_info.paid_service_access_info
|
||||
has_active_subscription = access.has_active_subscription if access else None
|
||||
active_subscription_is_paid = access.active_subscription_is_paid if access else None
|
||||
total_usable = access.total_usable_credits if access else None
|
||||
subscription_credits = access.subscription_credits_remaining if access else None
|
||||
purchased_credits = access.purchased_credits_remaining if access else None
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal credits are exhausted{credit_detail}, so {capability} "
|
||||
f"is unavailable. Top up or renew credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription and active_subscription_is_paid is False:
|
||||
return (
|
||||
f"Your current Nous Portal plan does not include paid service access, "
|
||||
f"so {capability} is unavailable. Upgrade or add credits at {billing_url}."
|
||||
)
|
||||
|
||||
if has_active_subscription is False:
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no active subscription or usable credits"
|
||||
f"{credit_detail}, so {capability} is unavailable. Subscribe or add credits "
|
||||
f"at {billing_url}."
|
||||
)
|
||||
|
||||
credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits)
|
||||
return (
|
||||
f"Your Nous Portal account has no usable paid credits{credit_detail}, so "
|
||||
f"{capability} is unavailable. Add credits or update billing at {billing_url}."
|
||||
)
|
||||
|
||||
|
||||
def _credit_detail(
|
||||
total_usable: Optional[float],
|
||||
subscription_credits: Optional[float],
|
||||
purchased_credits: Optional[float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if total_usable is not None:
|
||||
parts.append(f"usable ${total_usable:.2f}")
|
||||
if subscription_credits is not None:
|
||||
parts.append(f"subscription ${subscription_credits:.2f}")
|
||||
if purchased_credits is not None:
|
||||
parts.append(f"purchased ${purchased_credits:.2f}")
|
||||
if not parts:
|
||||
return ""
|
||||
return f" ({', '.join(parts)})"
|
||||
|
||||
|
||||
def reset_nous_portal_account_info_cache() -> None:
|
||||
"""Clear the short-lived account-info cache used by tests."""
|
||||
global _account_info_cache
|
||||
_account_info_cache = None
|
||||
|
||||
|
||||
def get_nous_portal_account_info(
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
min_jwt_ttl_seconds: int = 60,
|
||||
) -> NousPortalAccountInfo:
|
||||
"""Return normalized Nous Portal account entitlement information.
|
||||
|
||||
By default, a valid unexpired OAuth access JWT is used as a low-latency
|
||||
local account snapshot. ``force_fresh=True`` always calls
|
||||
``/api/oauth/account`` and bypasses the short-lived cache. JWT claims are
|
||||
decoded locally for UX gating only; server APIs remain authoritative.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
except Exception as exc:
|
||||
return _error_info(error=exc, logged_in=False)
|
||||
|
||||
access_token = state.get("access_token")
|
||||
portal_base_url = _portal_base_url(state)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
pool_oauth_info = _info_from_oauth_pool(
|
||||
force_fresh=force_fresh,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if pool_oauth_info is not None:
|
||||
return pool_oauth_info
|
||||
pool_info = _info_from_inference_key_pool(portal_base_url)
|
||||
if pool_info is not None:
|
||||
return pool_info
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="none",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=portal_base_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
return _fresh_account_info(
|
||||
state=state,
|
||||
force_fresh=force_fresh,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_account_info(
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
force_fresh: bool,
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
global _account_info_cache
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state, resolve_nous_access_token
|
||||
|
||||
access_token = resolve_nous_access_token()
|
||||
refreshed_state = get_provider_auth_state("nous") or state
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
|
||||
info = _info_from_account_payload(
|
||||
payload,
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=bool(state.get("access_token")),
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _info_from_inference_key_pool(
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
"""Return an explicit unknown-entitlement snapshot for opaque Nous keys."""
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
if entry is None:
|
||||
return None
|
||||
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
if not isinstance(runtime_key, str) or not runtime_key.strip():
|
||||
return None
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=False,
|
||||
source="inference_key",
|
||||
fresh=False,
|
||||
portal_base_url=(
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
),
|
||||
inference_base_url=(
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
inference_credential_present=True,
|
||||
credential_source=f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
error="portal_oauth_missing",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _info_from_oauth_pool(
|
||||
*,
|
||||
force_fresh: bool,
|
||||
min_jwt_ttl_seconds: int,
|
||||
portal_base_url: Optional[str],
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
entry = _select_nous_pool_entry()
|
||||
except Exception:
|
||||
return None
|
||||
if entry is None or not _pool_entry_is_portal_oauth(entry):
|
||||
return None
|
||||
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return None
|
||||
|
||||
entry_portal_url = (
|
||||
getattr(entry, "portal_base_url", None)
|
||||
or portal_base_url
|
||||
)
|
||||
state = {
|
||||
"access_token": access_token,
|
||||
"client_id": getattr(entry, "client_id", None),
|
||||
"inference_base_url": (
|
||||
getattr(entry, "inference_base_url", None)
|
||||
or getattr(entry, "runtime_base_url", None)
|
||||
or getattr(entry, "base_url", None)
|
||||
),
|
||||
"agent_key": getattr(entry, "agent_key", None),
|
||||
"credential_source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
}
|
||||
|
||||
if not force_fresh:
|
||||
jwt_info = _info_from_valid_jwt(
|
||||
access_token,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
min_jwt_ttl_seconds=min_jwt_ttl_seconds,
|
||||
)
|
||||
if jwt_info is not None:
|
||||
return jwt_info
|
||||
|
||||
try:
|
||||
payload = _fetch_nous_account_info(access_token, entry_portal_url)
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
error=exc,
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if not payload:
|
||||
return _error_info(
|
||||
error="empty_account_response",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
if isinstance(payload.get("error"), str):
|
||||
return _error_info(
|
||||
error=payload.get("error") or "account_response_error",
|
||||
logged_in=True,
|
||||
portal_base_url=entry_portal_url,
|
||||
raw_account=payload,
|
||||
)
|
||||
return _info_from_account_payload(
|
||||
payload,
|
||||
state=state,
|
||||
portal_base_url=entry_portal_url,
|
||||
)
|
||||
|
||||
|
||||
def _select_nous_pool_entry() -> Optional[Any]:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("nous")
|
||||
if not pool or not pool.has_credentials():
|
||||
return None
|
||||
entries = list(pool.entries())
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
|
||||
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
|
||||
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
|
||||
priority = int(getattr(entry, "priority", 0) or 0)
|
||||
return (agent_exp, access_exp, -priority)
|
||||
|
||||
return max(entries, key=_entry_sort_key)
|
||||
|
||||
|
||||
def _pool_entry_is_portal_oauth(entry: Any) -> bool:
|
||||
access_token = getattr(entry, "access_token", None)
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
return False
|
||||
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
|
||||
refresh_token = getattr(entry, "refresh_token", None)
|
||||
return auth_type.startswith("oauth") or bool(refresh_token)
|
||||
|
||||
|
||||
def _fetch_nous_account_info(
|
||||
access_token: str,
|
||||
portal_base_url: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
url = f"{base}/api/oauth/account"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _info_from_valid_jwt(
|
||||
token: str,
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
min_jwt_ttl_seconds: int,
|
||||
) -> Optional[NousPortalAccountInfo]:
|
||||
try:
|
||||
from hermes_cli.auth import _decode_jwt_claims
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
claims = _decode_jwt_claims(token)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
exp = _coerce_float(claims.get("exp"))
|
||||
if exp is None or exp <= time.time() + max(0, int(min_jwt_ttl_seconds)):
|
||||
return None
|
||||
|
||||
paid_access = _coerce_bool(claims.get("paid_access"))
|
||||
subscription_tier = _coerce_int(claims.get("subscription_tier"))
|
||||
access_info = NousPaidServiceAccessInfo(
|
||||
allowed=paid_access,
|
||||
paid_access=paid_access,
|
||||
organisation_id=_coerce_str(claims.get("org_id")),
|
||||
subscription_tier=subscription_tier,
|
||||
)
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="jwt",
|
||||
fresh=False,
|
||||
user_id=_coerce_str(claims.get("sub")),
|
||||
org_id=_coerce_str(claims.get("org_id")),
|
||||
client_id=_coerce_str(claims.get("client_id") or state.get("client_id")),
|
||||
product_id=_coerce_str(claims.get("product_id")),
|
||||
nous_client=_coerce_str(claims.get("nous_client")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=True,
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access_info,
|
||||
raw_claims=dict(claims),
|
||||
)
|
||||
|
||||
|
||||
def _info_from_account_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
organisation = (
|
||||
payload.get("organisation")
|
||||
if isinstance(payload.get("organisation"), dict)
|
||||
else {}
|
||||
)
|
||||
subscription = _subscription_from_payload(payload.get("subscription"))
|
||||
access = _paid_service_access_from_payload(payload.get("paid_service_access"))
|
||||
paid_access = access.allowed if access else None
|
||||
if paid_access is None and access is not None:
|
||||
paid_access = access.paid_access
|
||||
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="account_api",
|
||||
fresh=True,
|
||||
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
|
||||
client_id=_coerce_str(state.get("client_id")),
|
||||
portal_base_url=portal_base_url,
|
||||
inference_base_url=_coerce_str(state.get("inference_base_url")),
|
||||
inference_credential_present=bool(state.get("access_token") or state.get("agent_key")),
|
||||
credential_source=_coerce_str(state.get("credential_source")) or "auth_store",
|
||||
email=_coerce_str(user.get("email")),
|
||||
privy_did=_coerce_str(user.get("privy_did")),
|
||||
subscription=subscription,
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access,
|
||||
raw_account=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return NousPortalSubscriptionInfo(
|
||||
plan=_coerce_str(value.get("plan")),
|
||||
tier=_coerce_int(value.get("tier")),
|
||||
monthly_charge=_coerce_float(value.get("monthly_charge")),
|
||||
current_period_end=_coerce_str(value.get("current_period_end")),
|
||||
credits_remaining=_coerce_float(value.get("credits_remaining")),
|
||||
rollover_credits=_coerce_float(value.get("rollover_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _paid_service_access_from_payload(value: Any) -> Optional[NousPaidServiceAccessInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
allowed = _coerce_bool(value.get("allowed"))
|
||||
paid_access = _coerce_bool(value.get("paid_access"))
|
||||
return NousPaidServiceAccessInfo(
|
||||
allowed=allowed,
|
||||
paid_access=paid_access,
|
||||
reason=_coerce_str(value.get("reason")),
|
||||
organisation_id=_coerce_str(value.get("organisation_id")),
|
||||
effective_at_ms=_coerce_int(value.get("effective_at_ms")),
|
||||
has_active_subscription=_coerce_bool(value.get("has_active_subscription")),
|
||||
active_subscription_is_paid=_coerce_bool(value.get("active_subscription_is_paid")),
|
||||
subscription_tier=_coerce_int(value.get("subscription_tier")),
|
||||
subscription_monthly_charge=_coerce_float(value.get("subscription_monthly_charge")),
|
||||
subscription_credits_remaining=_coerce_float(value.get("subscription_credits_remaining")),
|
||||
purchased_credits_remaining=_coerce_float(value.get("purchased_credits_remaining")),
|
||||
total_usable_credits=_coerce_float(value.get("total_usable_credits")),
|
||||
)
|
||||
|
||||
|
||||
def _error_info(
|
||||
*,
|
||||
error: object,
|
||||
logged_in: bool,
|
||||
portal_base_url: Optional[str] = None,
|
||||
raw_account: Optional[dict[str, Any]] = None,
|
||||
) -> NousPortalAccountInfo:
|
||||
return NousPortalAccountInfo(
|
||||
logged_in=logged_in,
|
||||
source="error",
|
||||
fresh=False,
|
||||
portal_base_url=portal_base_url,
|
||||
raw_account=raw_account,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _portal_base_url(state: dict[str, Any]) -> Optional[str]:
|
||||
value = state.get("portal_base_url")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _cache_key(access_token: str, portal_base_url: Optional[str]) -> str:
|
||||
digest = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
|
||||
return f"{portal_base_url or ''}:{digest}"
|
||||
|
||||
|
||||
def _parse_iso_timestamp(value: Any) -> Optional[float]:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_str(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> Optional[bool]:
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
|
@ -6,8 +6,8 @@ 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, 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 (
|
||||
|
|
@ -53,6 +53,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:
|
||||
|
|
@ -235,12 +236,16 @@ def get_nous_subscription_features(
|
|||
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
|
||||
|
||||
try:
|
||||
nous_status = get_nous_auth_status()
|
||||
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"))
|
||||
managed_tools_flag = bool(
|
||||
account_info
|
||||
and account_info.logged_in
|
||||
and account_info.paid_service_access is True
|
||||
)
|
||||
nous_auth_present = bool(account_info and account_info.logged_in)
|
||||
subscribed = provider_is_nous or nous_auth_present
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
|
|
@ -483,6 +488,7 @@ def get_nous_subscription_features(
|
|||
nous_auth_present=nous_auth_present,
|
||||
provider_is_nous=provider_is_nous,
|
||||
features=features,
|
||||
account_info=account_info,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ from hermes_cli.auth import AuthError, resolve_provider
|
|||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.nous_account import (
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
|
@ -193,26 +197,57 @@ def show_status(args):
|
|||
qwen_status = {}
|
||||
minimax_status = {}
|
||||
|
||||
nous_logged_in = bool(nous_status.get("logged_in"))
|
||||
nous_account_info = None
|
||||
if (
|
||||
nous_status.get("logged_in")
|
||||
or nous_status.get("access_token")
|
||||
or nous_status.get("portal_base_url")
|
||||
or nous_status.get("inference_credential_present")
|
||||
or nous_status.get("error_code")
|
||||
):
|
||||
try:
|
||||
nous_account_info = get_nous_portal_account_info()
|
||||
except Exception:
|
||||
nous_account_info = None
|
||||
|
||||
nous_logged_in = bool(
|
||||
nous_status.get("logged_in")
|
||||
or (nous_account_info and nous_account_info.logged_in)
|
||||
)
|
||||
nous_inference_present = bool(
|
||||
nous_status.get("inference_credential_present")
|
||||
or (nous_account_info and nous_account_info.inference_credential_present)
|
||||
)
|
||||
nous_error = nous_status.get("error")
|
||||
nous_label = "logged in" if nous_logged_in else "not logged in (run: hermes auth add nous --type oauth)"
|
||||
if nous_logged_in:
|
||||
nous_label = "logged in"
|
||||
elif nous_inference_present:
|
||||
nous_label = "not logged in (Nous inference key configured)"
|
||||
else:
|
||||
nous_label = "not logged in (run: hermes auth add nous --type oauth)"
|
||||
print(
|
||||
f" {'Nous Portal':<12} {check_mark(nous_logged_in)} "
|
||||
f"{nous_label}"
|
||||
)
|
||||
portal_url = nous_status.get("portal_base_url") or "(unknown)"
|
||||
inference_url = (
|
||||
nous_status.get("inference_base_url")
|
||||
or (nous_account_info.inference_base_url if nous_account_info else None)
|
||||
)
|
||||
access_exp = _format_iso_timestamp(nous_status.get("access_expires_at"))
|
||||
key_exp = _format_iso_timestamp(nous_status.get("agent_key_expires_at"))
|
||||
refresh_label = "yes" if nous_status.get("has_refresh_token") else "no"
|
||||
if nous_logged_in or portal_url != "(unknown)" or nous_error:
|
||||
print(f" Portal URL: {portal_url}")
|
||||
if nous_inference_present and inference_url:
|
||||
print(f" Inference: {inference_url}")
|
||||
if nous_logged_in or nous_status.get("access_expires_at"):
|
||||
print(f" Access exp: {access_exp}")
|
||||
if nous_logged_in or nous_status.get("agent_key_expires_at"):
|
||||
if nous_logged_in or nous_inference_present or nous_status.get("agent_key_expires_at"):
|
||||
print(f" Key exp: {key_exp}")
|
||||
if nous_logged_in or nous_status.get("has_refresh_token"):
|
||||
print(f" Refresh: {refresh_label}")
|
||||
if nous_error and not nous_logged_in:
|
||||
if nous_error:
|
||||
print(f" Error: {nous_error}")
|
||||
|
||||
codex_logged_in = bool(codex_status.get("logged_in"))
|
||||
|
|
@ -303,18 +338,18 @@ def show_status(args):
|
|||
else:
|
||||
state = "not configured"
|
||||
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
||||
elif nous_logged_in:
|
||||
# Logged into Nous but on the free tier — show upgrade nudge
|
||||
elif nous_logged_in or nous_inference_present:
|
||||
# Nous OAuth without entitlement, or an opaque inference key without
|
||||
# Portal account information, cannot enable the Tool Gateway.
|
||||
print()
|
||||
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
|
||||
print(" Your free-tier Nous account does not include Tool Gateway access.")
|
||||
print(" Upgrade your subscription to unlock managed web, image, TTS, and browser tools.")
|
||||
try:
|
||||
portal_url = nous_status.get("portal_base_url", "").rstrip("/")
|
||||
if portal_url:
|
||||
print(f" Upgrade: {portal_url}")
|
||||
except Exception:
|
||||
pass
|
||||
message = format_nous_portal_entitlement_message(
|
||||
nous_account_info,
|
||||
capability="managed web, image, TTS, browser, and Modal tools",
|
||||
)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
# =========================================================================
|
||||
# API-Key Providers
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from hermes_cli.nous_subscription import (
|
|||
apply_nous_managed_defaults,
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
from hermes_cli.nous_account import format_nous_portal_entitlement_message
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, managed_nous_tools_enabled
|
||||
from utils import base_url_hostname, is_truthy_value
|
||||
|
||||
|
|
@ -1855,6 +1856,20 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
|||
return visible
|
||||
|
||||
|
||||
def _hidden_nous_gateway_message(cat: dict, config: dict, capability: str) -> str:
|
||||
"""Return a reason when a category's Nous provider is hidden."""
|
||||
if managed_nous_tools_enabled():
|
||||
return ""
|
||||
if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])):
|
||||
return ""
|
||||
features = get_nous_subscription_features(config)
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=capability,
|
||||
)
|
||||
return message or ""
|
||||
|
||||
|
||||
_POST_SETUP_INSTALLED: dict = {
|
||||
# post_setup_key -> predicate(): True when the install side-effect
|
||||
# is already satisfied. Used by `_toolset_needs_configuration_prompt`
|
||||
|
|
@ -1955,6 +1970,11 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
|||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = _visible_providers(cat, config)
|
||||
hidden_nous_message = _hidden_nous_gateway_message(
|
||||
cat,
|
||||
config,
|
||||
f"the Nous Subscription provider for {name}",
|
||||
)
|
||||
|
||||
# Check Python version requirement
|
||||
if cat.get("requires_python"):
|
||||
|
|
@ -1975,6 +1995,9 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
|||
# For single-provider tools, show a note if available
|
||||
if cat.get("setup_note"):
|
||||
_print_info(f" {cat['setup_note']}")
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
_configure_provider(provider, config)
|
||||
else:
|
||||
# Multiple providers - let user choose
|
||||
|
|
@ -1984,6 +2007,9 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
|||
print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN))
|
||||
if cat.get("setup_note"):
|
||||
_print_info(f" {cat['setup_note']}")
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
print()
|
||||
|
||||
# Plain text labels only (no ANSI codes in menu items)
|
||||
|
|
@ -2410,8 +2436,17 @@ def _configure_provider(provider: dict, config: dict):
|
|||
|
||||
if provider.get("requires_nous_auth"):
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.nous_auth_present:
|
||||
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
)
|
||||
if not features.nous_auth_present or not entitled:
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=f"{provider.get('name', 'Nous Subscription')}",
|
||||
)
|
||||
_print_warning(
|
||||
f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}"
|
||||
)
|
||||
return
|
||||
|
||||
# Set TTS provider in config if applicable
|
||||
|
|
@ -2680,15 +2715,26 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
|||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = _visible_providers(cat, config)
|
||||
hidden_nous_message = _hidden_nous_gateway_message(
|
||||
cat,
|
||||
config,
|
||||
f"the Nous Subscription provider for {name}",
|
||||
)
|
||||
|
||||
if len(providers) == 1:
|
||||
provider = providers[0]
|
||||
print()
|
||||
print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
_reconfigure_provider(provider, config)
|
||||
else:
|
||||
print()
|
||||
print(color(f" --- {icon} {name} - Choose a provider ---", Colors.CYAN))
|
||||
if hidden_nous_message:
|
||||
for line in hidden_nous_message.splitlines():
|
||||
_print_warning(f" {line}")
|
||||
print()
|
||||
|
||||
provider_choices = []
|
||||
|
|
@ -2719,8 +2765,17 @@ def _reconfigure_provider(provider: dict, config: dict):
|
|||
|
||||
if provider.get("requires_nous_auth"):
|
||||
features = get_nous_subscription_features(config)
|
||||
if not features.nous_auth_present:
|
||||
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
)
|
||||
if not features.nous_auth_present or not entitled:
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=f"{provider.get('name', 'Nous Subscription')}",
|
||||
)
|
||||
_print_warning(
|
||||
f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}"
|
||||
)
|
||||
return
|
||||
|
||||
if provider.get("tts_provider"):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue