hermes-agent/hermes_cli/nous_account.py

678 lines
24 KiB
Python

"""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