4a3942d948
When the Nous Portal returns paid_service_access.allowed=false with reason=member_spend_cap_exceeded, Hermes was falling through to the generic 'no active subscription or usable credits' message — even though the user has ample purchased credits and the real blocker is an org-level per-member spend cap. This adds a dedicated branch that surfaces the actual cause: names the spend cap, shows the cap/spend amounts, and tells the user to ask their org admin to raise it. Also adds member_spend_cap_exceeded to the billing error code set so the error classifier and auth error formatter route it through the Nous entitlement message path.
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""Tests for normalized Nous Portal account entitlement helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.nous_account import (
|
|
NousPaidServiceAccessInfo,
|
|
NousPortalAccountInfo,
|
|
format_nous_portal_entitlement_message,
|
|
get_nous_portal_account_info,
|
|
nous_portal_topup_url,
|
|
reset_nous_portal_account_info_cache,
|
|
)
|
|
|
|
|
|
def _jwt(claims: dict[str, Any]) -> str:
|
|
def _part(payload: dict[str, Any]) -> str:
|
|
raw = json.dumps(payload, separators=(",", ":")).encode()
|
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
|
|
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
|
|
|
|
|
|
def _state(token: str) -> dict[str, Any]:
|
|
return {
|
|
"access_token": token,
|
|
"portal_base_url": "https://portal.example.test",
|
|
"client_id": "hermes-cli",
|
|
}
|
|
|
|
|
|
def _account_payload(
|
|
*,
|
|
allowed: bool,
|
|
subscription: dict[str, Any] | None,
|
|
subscription_credits: float,
|
|
purchased_credits: float,
|
|
member_spend_cap_exceeded: bool | None = None,
|
|
member_spend_cap_usd: float | str | None = None,
|
|
member_spend_usd: float | str | None = None,
|
|
member_spend_cap_remaining_usd: float | str | None = None,
|
|
) -> dict[str, Any]:
|
|
psa: dict[str, Any] = {
|
|
"allowed": allowed,
|
|
"paid_access": allowed,
|
|
"reason": "usable_credits" if allowed else "no_usable_credits",
|
|
"organisation_id": "org_123",
|
|
"effective_at_ms": 123456789,
|
|
"has_active_subscription": subscription is not None,
|
|
"active_subscription_is_paid": bool(
|
|
subscription and subscription.get("monthly_charge", 0) > 0
|
|
),
|
|
"subscription_tier": subscription.get("tier") if subscription else None,
|
|
"subscription_monthly_charge": (
|
|
subscription.get("monthly_charge") if subscription else None
|
|
),
|
|
"subscription_credits_remaining": subscription_credits,
|
|
"purchased_credits_remaining": purchased_credits,
|
|
"total_usable_credits": subscription_credits + purchased_credits,
|
|
}
|
|
if member_spend_cap_exceeded is not None:
|
|
psa["member_spend_cap_exceeded"] = member_spend_cap_exceeded
|
|
psa["reason"] = "member_spend_cap_exceeded"
|
|
if member_spend_cap_usd is not None:
|
|
psa["member_spend_cap_usd"] = member_spend_cap_usd
|
|
if member_spend_usd is not None:
|
|
psa["member_spend_usd"] = member_spend_usd
|
|
if member_spend_cap_remaining_usd is not None:
|
|
psa["member_spend_cap_remaining_usd"] = member_spend_cap_remaining_usd
|
|
return {
|
|
"user": {
|
|
"email": "alice@example.test",
|
|
"privy_did": "did:privy:alice",
|
|
},
|
|
"organisation": {
|
|
"id": "org_123",
|
|
},
|
|
"subscription": subscription,
|
|
"purchased_credits_remaining": purchased_credits,
|
|
"paid_service_access": psa,
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_cache():
|
|
reset_nous_portal_account_info_cache()
|
|
yield
|
|
reset_nous_portal_account_info_cache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "expected_paid"),
|
|
[
|
|
(
|
|
_account_payload(
|
|
allowed=True,
|
|
subscription={
|
|
"plan": "Tier 2",
|
|
"tier": 2,
|
|
"monthly_charge": 20,
|
|
"current_period_end": "2026-05-01T00:00:00.000Z",
|
|
"credits_remaining": 12.25,
|
|
"rollover_credits": 3.5,
|
|
},
|
|
subscription_credits=12.25,
|
|
purchased_credits=7.75,
|
|
),
|
|
True,
|
|
),
|
|
(
|
|
_account_payload(
|
|
allowed=False,
|
|
subscription={
|
|
"plan": "Tier 2",
|
|
"tier": 2,
|
|
"monthly_charge": 20,
|
|
"current_period_end": "2026-05-01T00:00:00.000Z",
|
|
"credits_remaining": 0,
|
|
"rollover_credits": 0,
|
|
},
|
|
subscription_credits=0,
|
|
purchased_credits=0,
|
|
),
|
|
False,
|
|
),
|
|
(
|
|
_account_payload(
|
|
allowed=True,
|
|
subscription=None,
|
|
subscription_credits=0,
|
|
purchased_credits=7.75,
|
|
),
|
|
True,
|
|
),
|
|
(
|
|
_account_payload(
|
|
allowed=False,
|
|
subscription=None,
|
|
subscription_credits=0,
|
|
purchased_credits=0,
|
|
),
|
|
False,
|
|
),
|
|
],
|
|
)
|
|
def test_fresh_account_payload_normalization(monkeypatch, payload, expected_paid):
|
|
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
|
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
|
|
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
|
|
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
|
|
|
|
info = get_nous_portal_account_info(force_fresh=True)
|
|
|
|
assert isinstance(info, NousPortalAccountInfo)
|
|
assert info.source == "account_api"
|
|
assert info.fresh is True
|
|
assert info.email == "alice@example.test"
|
|
assert info.privy_did == "did:privy:alice"
|
|
assert info.org_id == "org_123"
|
|
assert info.paid_service_access is expected_paid
|
|
assert info.is_paid is expected_paid
|
|
assert info.is_free_tier is (not expected_paid)
|
|
|
|
|
|
def test_no_oauth_token_reports_inference_key_present(monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
|
|
|
|
class _Entry:
|
|
label = "manual-nous"
|
|
access_token = ""
|
|
agent_key = "opaque-runtime-key"
|
|
agent_key_expires_at = "2099-01-01T00:00:00+00:00"
|
|
expires_at = None
|
|
inference_base_url = "https://inference.example.test/v1"
|
|
base_url = "https://inference.example.test/v1"
|
|
priority = 0
|
|
|
|
@property
|
|
def runtime_api_key(self):
|
|
return self.agent_key
|
|
|
|
@property
|
|
def runtime_base_url(self):
|
|
return self.inference_base_url
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def entries(self):
|
|
return [_Entry()]
|
|
|
|
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
|
|
|
|
info = get_nous_portal_account_info()
|
|
|
|
assert info.logged_in is False
|
|
assert info.source == "inference_key"
|
|
assert info.inference_credential_present is True
|
|
assert info.credential_source == "pool:manual-nous"
|
|
assert info.paid_service_access is None
|
|
|
|
|
|
def test_pool_oauth_entry_force_fresh_uses_account_api(monkeypatch):
|
|
token = _jwt(
|
|
{
|
|
"sub": "user_123",
|
|
"org_id": "org_123",
|
|
"exp": int(time.time()) + 900,
|
|
"paid_access": False,
|
|
}
|
|
)
|
|
payload = _account_payload(
|
|
allowed=True,
|
|
subscription=None,
|
|
subscription_credits=0,
|
|
purchased_credits=3,
|
|
)
|
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
|
|
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
|
|
|
|
class _Entry:
|
|
label = "dashboard device_code"
|
|
auth_type = "oauth"
|
|
access_token = token
|
|
refresh_token = "refresh-token"
|
|
agent_key = "opaque-runtime-key"
|
|
agent_key_expires_at = "2099-01-01T00:00:00+00:00"
|
|
expires_at = "2099-01-01T00:00:00+00:00"
|
|
portal_base_url = "https://portal.example.test"
|
|
inference_base_url = "https://inference.example.test/v1"
|
|
base_url = "https://inference.example.test/v1"
|
|
priority = 0
|
|
|
|
@property
|
|
def runtime_api_key(self):
|
|
return self.agent_key
|
|
|
|
@property
|
|
def runtime_base_url(self):
|
|
return self.inference_base_url
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def entries(self):
|
|
return [_Entry()]
|
|
|
|
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
|
|
|
|
info = get_nous_portal_account_info(force_fresh=True)
|
|
|
|
assert info.logged_in is True
|
|
assert info.source == "account_api"
|
|
assert info.fresh is True
|
|
assert info.paid_service_access is True
|
|
assert info.credential_source == "pool:dashboard device_code"
|
|
|
|
|
|
# ── member spend cap exceeded ───────────────────────────────────────────────
|
|
|
|
|
|
def test_member_spend_cap_exceeded_message(monkeypatch):
|
|
"""When the Portal returns member_spend_cap_exceeded, the entitlement
|
|
message should explain the cap — not say 'no credits'."""
|
|
payload = _account_payload(
|
|
allowed=False,
|
|
subscription=None,
|
|
subscription_credits=0,
|
|
purchased_credits=222990.17,
|
|
member_spend_cap_exceeded=True,
|
|
member_spend_cap_usd="500",
|
|
member_spend_usd="520.51",
|
|
member_spend_cap_remaining_usd="0",
|
|
)
|
|
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
|
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
|
|
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
|
|
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
|
|
|
|
info = get_nous_portal_account_info(force_fresh=True)
|
|
|
|
assert info.paid_service_access is False
|
|
assert info.paid_service_access_info is not None
|
|
assert info.paid_service_access_info.member_spend_cap_exceeded is True
|
|
assert info.paid_service_access_info.member_spend_cap_usd == 500.0
|
|
assert info.paid_service_access_info.member_spend_usd == 520.51
|
|
|
|
msg = format_nous_portal_entitlement_message(info, capability="Nous model access")
|
|
assert msg is not None
|
|
# Must mention spend cap, not "no active subscription or usable credits"
|
|
assert "spend cap" in msg
|
|
assert "$500.00" in msg
|
|
assert "$520.51" in msg
|
|
# Should NOT say "no active subscription"
|
|
assert "no active subscription" not in msg
|
|
# Should still show available credits
|
|
assert "$222990.17" in msg
|
|
|
|
|
|
def test_member_spend_cap_exceeded_without_amounts(monkeypatch):
|
|
"""Even if the Portal doesn't include cap/spend amounts, the message
|
|
should still mention the spend cap rather than credits."""
|
|
payload = _account_payload(
|
|
allowed=False,
|
|
subscription=None,
|
|
subscription_credits=0,
|
|
purchased_credits=100,
|
|
member_spend_cap_exceeded=True,
|
|
)
|
|
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
|
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
|
|
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
|
|
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
|
|
|
|
info = get_nous_portal_account_info(force_fresh=True)
|
|
msg = format_nous_portal_entitlement_message(info, capability="Nous model access")
|
|
assert msg is not None
|
|
assert "spend cap" in msg
|
|
assert "no active subscription" not in msg
|
|
|
|
|
|
|
|
|
|
# ── org slug/name parsing + top-up URL builder ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|