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:
Robin Fernandes 2026-05-25 15:10:14 +10:00 committed by Teknium
parent 0bf9b867cf
commit 406901b27d
32 changed files with 2470 additions and 181 deletions

View file

@ -667,6 +667,42 @@ def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch):
assert "example.com" in str(status.get("portal_base_url", ""))
def test_get_nous_auth_status_pool_opaque_key_is_not_portal_login(tmp_path, monkeypatch):
from hermes_cli.auth import get_nous_auth_status, invalidate_nous_auth_status_cache
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1, "providers": {},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
invalidate_nous_auth_status_cache()
from agent.credential_pool import PooledCredential, load_pool
pool = load_pool("nous")
entry = PooledCredential.from_dict("nous", {
"access_token": "",
"agent_key": "opaque-agent-key",
"agent_key_expires_at": "2099-01-01T00:00:00+00:00",
"label": "manual opaque key",
"auth_type": "api_key",
"source": "manual",
"base_url": "https://inference.example.com/v1",
"inference_base_url": "https://inference.example.com/v1",
})
pool.add_entry(entry)
status = get_nous_auth_status()
assert status["logged_in"] is False
assert status["inference_credential_present"] is True
assert status["credential_source"] == "pool:manual opaque key"
assert status.get("access_token") is None
assert status.get("portal_base_url") is None
assert status.get("inference_base_url") == "https://inference.example.com/v1"
invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_auth_store_fallback(tmp_path, monkeypatch):
"""get_nous_auth_status() falls back to auth store when credential
pool is empty.
@ -1023,12 +1059,19 @@ class TestLoginNousSkipKeepsCurrent:
lambda *a, **kw: prompt_returns,
)
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda p: {})
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda: None)
free_tier_calls = []
def _check_nous_free_tier(**kwargs):
free_tier_calls.append(kwargs)
return None
monkeypatch.setattr(models_mod, "check_nous_free_tier", _check_nous_free_tier)
monkeypatch.setattr(
models_mod, "partition_nous_models_by_tier",
lambda ids, p, free_tier=False: (ids, []),
)
monkeypatch.setattr(ns, "prompt_enable_tool_gateway", lambda cfg: None)
return free_tier_calls
def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch):
"""User picks Skip → config.yaml untouched, Nous creds still saved."""
@ -1070,7 +1113,7 @@ class TestLoginNousSkipKeepsCurrent:
hermes_home, config_path, auth_path = self._setup_home_with_openrouter(
tmp_path, monkeypatch,
)
self._patch_login_internals(
free_tier_calls = self._patch_login_internals(
monkeypatch, prompt_returns="xiaomi/mimo-v2-pro",
)
@ -1083,6 +1126,7 @@ class TestLoginNousSkipKeepsCurrent:
cfg_after = yaml.safe_load(config_path.read_text())
assert cfg_after["model"]["provider"] == "nous"
assert cfg_after["model"]["default"] == "xiaomi/mimo-v2-pro"
assert free_tier_calls == [{"force_fresh": True}]
auth_after = json.loads(auth_path.read_text())
assert auth_after["active_provider"] == "nous"

View file

@ -2,6 +2,7 @@
from unittest.mock import patch, MagicMock
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli.models import (
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
is_nous_free_tier, partition_nous_models_by_tier,
@ -308,6 +309,15 @@ class TestDetectProviderForModel:
class TestIsNousFreeTier:
"""Tests for is_nous_free_tier — account tier detection."""
def test_paid_service_access_allowed_true_is_not_free(self):
assert is_nous_free_tier({"paid_service_access": {"allowed": True}}) is False
def test_paid_service_access_allowed_false_is_free(self):
assert is_nous_free_tier({"paid_service_access": {"allowed": False}}) is True
def test_paid_service_access_paid_access_fallback(self):
assert is_nous_free_tier({"paid_service_access": {"paid_access": False}}) is True
def test_paid_plus_tier(self):
assert is_nous_free_tier({"subscription": {"plan": "Plus", "tier": 2, "monthly_charge": 20}}) is False
@ -657,39 +667,58 @@ class TestCheckNousFreeTierCache:
def teardown_method(self):
_models_mod._free_tier_cache = None
@patch("hermes_cli.models.fetch_nous_account_tier")
@patch("hermes_cli.models.is_nous_free_tier", return_value=True)
def test_result_is_cached(self, mock_is_free, mock_fetch):
"""Second call within TTL returns cached result without API call."""
mock_fetch.return_value = {"subscription": {"monthly_charge": 0}}
with patch("hermes_cli.auth.get_provider_auth_state", return_value={"access_token": "tok"}), \
patch("hermes_cli.auth.resolve_nous_runtime_credentials"):
result1 = check_nous_free_tier()
result2 = check_nous_free_tier()
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_result_is_cached(self, mock_account):
"""Second call within TTL returns cached result without account lookup."""
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=False,
)
result1 = check_nous_free_tier()
result2 = check_nous_free_tier()
assert result1 is True
assert result2 is True
assert mock_fetch.call_count == 1
assert mock_account.call_count == 1
@patch("hermes_cli.models.fetch_nous_account_tier")
@patch("hermes_cli.models.is_nous_free_tier", return_value=False)
def test_cache_expires_after_ttl(self, mock_is_free, mock_fetch):
"""After TTL expires, the API is called again."""
mock_fetch.return_value = {"subscription": {"monthly_charge": 20}}
with patch("hermes_cli.auth.get_provider_auth_state", return_value={"access_token": "tok"}), \
patch("hermes_cli.auth.resolve_nous_runtime_credentials"):
result1 = check_nous_free_tier()
assert mock_fetch.call_count == 1
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_cache_expires_after_ttl(self, mock_account):
"""After TTL expires, account info is resolved again."""
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
)
result1 = check_nous_free_tier()
assert mock_account.call_count == 1
cached_result, cached_at = _models_mod._free_tier_cache
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
cached_result, cached_at = _models_mod._free_tier_cache
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
result2 = check_nous_free_tier()
assert mock_fetch.call_count == 2
result2 = check_nous_free_tier()
assert mock_account.call_count == 2
assert result1 is False
assert result2 is False
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_force_fresh_bypasses_cache(self, mock_account):
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
)
assert check_nous_free_tier() is False
assert check_nous_free_tier(force_fresh=True) is False
assert mock_account.call_count == 2
mock_account.assert_called_with(force_fresh=True)
def test_cache_ttl_is_short(self):
"""TTL should be short enough to catch upgrades quickly (<=5 min)."""
assert _FREE_TIER_CACHE_TTL <= 300

View file

@ -0,0 +1,547 @@
"""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,
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,
) -> dict[str, Any]:
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": {
"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,
},
}
@pytest.fixture(autouse=True)
def _reset_cache():
reset_nous_portal_account_info_cache()
yield
reset_nous_portal_account_info_cache()
def test_valid_jwt_with_paid_access_true(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"client_id": "hermes-cli",
"product_id": "nous-hermes-agent",
"nous_client": "hermes-agent",
"exp": int(time.time()) + 900,
"paid_access": True,
"subscription_tier": 2,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.fresh is False
assert info.logged_in is True
assert info.user_id == "user_123"
assert info.org_id == "org_123"
assert info.product_id == "nous-hermes-agent"
assert info.paid_service_access is True
assert info.is_paid is True
assert info.is_free_tier is False
def test_valid_jwt_with_paid_access_false(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) + 900,
"paid_access": False,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.paid_service_access is False
assert info.is_paid is False
assert info.is_free_tier is True
def test_valid_jwt_missing_paid_access_is_unknown_not_paid(monkeypatch):
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))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.paid_service_access is None
assert info.is_paid is False
assert info.is_free_tier is False
def test_expired_jwt_falls_back_to_fresh_account(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) - 60,
"paid_access": False,
}
)
payload = _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,
)
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()
assert info.source == "account_api"
assert info.fresh is True
assert info.paid_service_access is True
assert info.subscription is not None
assert info.subscription.monthly_charge == 20
assert info.paid_service_access_info is not None
assert info.paid_service_access_info.total_usable_credits == 20
@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_force_fresh_uses_account_api_even_when_jwt_is_valid(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=5,
)
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.source == "account_api"
assert info.paid_service_access is True
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_uses_jwt_snapshot(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"client_id": "hermes-cli",
"exp": int(time.time()) + 900,
"paid_access": True,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
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()
assert info.logged_in is True
assert info.source == "jwt"
assert info.paid_service_access is True
assert info.credential_source == "pool:dashboard device_code"
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"
def test_entitlement_message_returns_none_for_paid_access():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
portal_base_url="https://portal.example.test",
)
assert format_nous_portal_entitlement_message(info, capability="paid models") is None
def test_entitlement_message_for_inference_key_without_portal_login():
info = NousPortalAccountInfo(
logged_in=False,
source="inference_key",
fresh=False,
inference_credential_present=True,
portal_base_url="https://portal.example.test",
)
message = format_nous_portal_entitlement_message(
info,
capability="managed tools",
)
assert message is not None
assert "Nous inference credentials are configured" in message
assert "cannot verify your Nous Portal paid access" in message
assert "Log in with `hermes model`" in message
def test_entitlement_message_for_active_paid_subscription_with_no_credits():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
portal_base_url="https://portal.example.test",
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="no_usable_credits",
has_active_subscription=True,
active_subscription_is_paid=True,
subscription_credits_remaining=0,
purchased_credits_remaining=0,
total_usable_credits=0,
),
)
message = format_nous_portal_entitlement_message(
info,
capability="managed tools",
)
assert message is not None
assert "credits are exhausted" in message
assert "managed tools" in message
assert "https://portal.example.test/billing" in message
def test_entitlement_message_for_no_subscription_or_credits():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
portal_base_url="https://portal.example.test",
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="no_usable_credits",
has_active_subscription=False,
subscription_credits_remaining=0,
purchased_credits_remaining=0,
total_usable_credits=0,
),
)
message = format_nous_portal_entitlement_message(info, capability="paid models")
assert message is not None
assert "no active subscription or usable credits" in message
assert "Subscribe or add credits" in message
def test_entitlement_message_for_unknown_entitlement_is_explicit():
info = NousPortalAccountInfo(
logged_in=True,
source="error",
fresh=False,
paid_service_access=None,
portal_base_url="https://portal.example.test",
error="account_api_timeout",
)
message = format_nous_portal_entitlement_message(info, capability="Tool Gateway")
assert message is not None
assert "could not verify" in message
assert "account_api_timeout" in message
assert "Run `hermes model`" in message
def test_entitlement_message_for_account_missing():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="account_missing",
),
)
message = format_nous_portal_entitlement_message(info, capability="Tool Gateway")
assert message is not None
assert "could not find a Nous Portal account or organisation" in message

View file

@ -1,14 +1,25 @@
"""Tests for Nous subscription feature detection."""
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli import nous_subscription as ns
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
return NousPortalAccountInfo(
logged_in=logged_in,
source="jwt" if logged_in else "none",
fresh=False,
paid_service_access=paid,
)
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -26,8 +37,9 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "terminal")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -46,8 +58,9 @@ def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monke
def test_get_nous_subscription_features_marks_browser_use_as_managed_when_gateway_ready(monkeypatch):
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -78,8 +91,9 @@ def test_get_nous_subscription_features_uses_direct_browserbase_when_no_managed_
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -103,8 +117,9 @@ def test_get_nous_subscription_features_prefers_camofox_over_managed_browser_use
env = {"CAMOFOX_URL": "http://localhost:9377"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -133,8 +148,9 @@ def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(m
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@ -155,8 +171,9 @@ def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_o
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")

View file

@ -83,6 +83,87 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
assert "Key exp:" in output
def test_show_status_reports_nous_inference_key_without_portal_login(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
from hermes_cli.nous_account import NousPortalAccountInfo
import hermes_cli.auth as auth_mod
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
monkeypatch.setattr(
auth_mod,
"get_nous_auth_status",
lambda: {
"logged_in": False,
"inference_credential_present": True,
"credential_source": "pool:manual opaque key",
"inference_base_url": "https://inference.example.com/v1",
"agent_key_expires_at": "2099-01-01T00:00:00+00:00",
},
raising=False,
)
monkeypatch.setattr(
status_mod,
"get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=False,
source="inference_key",
fresh=False,
inference_credential_present=True,
inference_base_url="https://inference.example.com/v1",
),
raising=False,
)
monkeypatch.setattr(status_mod, "managed_nous_tools_enabled", lambda: False, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
output = capsys.readouterr().out
assert "Nous Portal ✗ not logged in (Nous inference key configured)" in output
assert "Inference: https://inference.example.com/v1" in output
assert "Nous inference credentials are configured" in output
def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
import hermes_cli.auth as auth_mod
import hermes_cli.gateway as gateway_mod
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
monkeypatch.setattr(status_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
monkeypatch.setattr(status_mod, "load_config", lambda: {"terminal": {"backend": "vercel_sandbox"}}, raising=False)
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
output = capsys.readouterr().out
assert "Backend: vercel_sandbox" in output
assert "Runtime: python3.13" in output
assert "Auth:" in output and "OIDC token via VERCEL_OIDC_TOKEN" in output
assert "Auth detail: mode: OIDC" in output
assert "Auth detail: active env: VERCEL_OIDC_TOKEN" in output
assert "oidc-token" not in output
assert "snapshot filesystem" in output
assert "live processes do not survive" in output
# ---------------------------------------------------------------------------
# Helpers shared by xAI OAuth status tests
# ---------------------------------------------------------------------------

View file

@ -2,6 +2,7 @@
from types import SimpleNamespace
from hermes_cli.nous_account import NousPaidServiceAccessInfo, NousPortalAccountInfo
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
@ -124,6 +125,59 @@ def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(mo
assert "Nous Tool Gateway" not in out
def test_show_status_reports_exhausted_nous_credits(monkeypatch, capsys, tmp_path):
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False)
from hermes_cli import status as status_mod
import hermes_cli.auth as auth_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
auth_mod,
"get_nous_auth_status",
lambda: {
"logged_in": False,
"access_token": "jwt",
"portal_base_url": "https://portal.example.test",
"error": "credits exhausted",
"error_code": "insufficient_credits",
},
raising=False,
)
monkeypatch.setattr(
status_mod,
"get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
portal_base_url="https://portal.example.test",
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="no_usable_credits",
has_active_subscription=True,
active_subscription_is_paid=True,
subscription_credits_remaining=0,
purchased_credits_remaining=0,
total_usable_credits=0,
),
),
raising=False,
)
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": {"provider": "nous"}}, raising=False)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Tool Gateway" in out
assert "credits are exhausted" in out
assert "https://portal.example.test/billing" in out
assert "free-tier Nous account" not in out
def test_show_status_reports_empty_lmstudio_listing_as_reachable(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod

View file

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli.tools_config import (
_DEFAULT_OFF_TOOLSETS,
_apply_toolset_change,
@ -557,8 +558,13 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
config = {"model": {"provider": "nous"}}
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_auth_status",
lambda: {"logged_in": True},
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
@ -571,8 +577,13 @@ def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monke
config = {"model": {"provider": "nous"}}
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_auth_status",
lambda: {"logged_in": True},
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
@ -657,8 +668,13 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
lambda: ["cli"],
)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_auth_status",
lambda: {"logged_in": True},
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
configured = []