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-28 00:19:31 -07:00
committed by Teknium
parent 0bf9b867cf
commit 406901b27d
32 changed files with 2470 additions and 181 deletions
+96
View File
@@ -992,6 +992,47 @@ class TestAuxiliaryPoolAwareness:
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
def test_call_llm_refreshes_nous_after_free_tier_block_when_account_paid(self):
from hermes_cli.nous_account import NousPortalAccountInfo
class _Payment404(Exception):
status_code = 404
stale_client = MagicMock()
stale_client.base_url = "https://inference-api.nousresearch.com/v1"
stale_client.chat.completions.create.side_effect = _Payment404(
"model_not_supported_on_free_tier: model is not available on the free tier"
)
fresh_client = MagicMock()
fresh_client.base_url = "https://inference-api.nousresearch.com/v1"
fresh_client.chat.completions.create.return_value = {"ok": True}
with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("nous", "nous-model", None, None, None)),
patch("agent.auxiliary_client._get_cached_client", return_value=(stale_client, "nous-model")),
patch("agent.auxiliary_client.OpenAI", return_value=fresh_client),
patch("agent.auxiliary_client._validate_llm_response", side_effect=lambda resp, _task: resp),
patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=("fresh-agent-key", "https://inference-api.nousresearch.com/v1")),
patch(
"hermes_cli.nous_account.get_nous_portal_account_info",
return_value=NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
),
),
):
result = call_llm(
task="compression",
messages=[{"role": "user", "content": "hi"}],
)
assert result == {"ok": True}
assert stale_client.chat.completions.create.call_count == 1
assert fresh_client.chat.completions.create.call_count == 1
@pytest.mark.asyncio
async def test_async_call_llm_retries_nous_after_401(self):
class _Auth401(Exception):
@@ -1021,6 +1062,48 @@ class TestAuxiliaryPoolAwareness:
assert stale_client.chat.completions.create.await_count == 1
assert fresh_async_client.chat.completions.create.await_count == 1
@pytest.mark.asyncio
async def test_async_call_llm_refreshes_nous_after_free_tier_block_when_account_paid(self):
from hermes_cli.nous_account import NousPortalAccountInfo
class _Payment404(Exception):
status_code = 404
stale_client = MagicMock()
stale_client.base_url = "https://inference-api.nousresearch.com/v1"
stale_client.chat.completions.create = AsyncMock(side_effect=_Payment404(
"model_not_supported_on_free_tier: model is not available on the free tier"
))
fresh_async_client = MagicMock()
fresh_async_client.base_url = "https://inference-api.nousresearch.com/v1"
fresh_async_client.chat.completions.create = AsyncMock(return_value={"ok": True})
with (
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("nous", "nous-model", None, None, None)),
patch("agent.auxiliary_client._get_cached_client", return_value=(stale_client, "nous-model")),
patch("agent.auxiliary_client._to_async_client", return_value=(fresh_async_client, "nous-model")),
patch("agent.auxiliary_client._validate_llm_response", side_effect=lambda resp, _task: resp),
patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=("fresh-agent-key", "https://inference-api.nousresearch.com/v1")),
patch(
"hermes_cli.nous_account.get_nous_portal_account_info",
return_value=NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
),
),
):
result = await async_call_llm(
task="session_search",
messages=[{"role": "user", "content": "hi"}],
)
assert result == {"ok": True}
assert stale_client.chat.completions.create.await_count == 1
assert fresh_async_client.chat.completions.create.await_count == 1
def test_cached_gmi_client_keeps_explicit_slash_model_override(self):
import agent.auxiliary_client as aux
@@ -1076,6 +1159,19 @@ class TestIsPaymentError:
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_404_free_tier_model_block_is_payment(self):
exc = Exception(
"Model 'gpt-5' is not available on the Free Tier. "
"Upgrade at https://portal.nousresearch.com or pick a free model."
)
exc.status_code = 404
assert _is_payment_error(exc) is True
def test_404_generic_not_found_is_not_payment(self):
exc = Exception("Not Found")
exc.status_code = 404
assert _is_payment_error(exc) is False
def test_429_without_credits_message_is_not_payment(self):
"""Normal rate limits should NOT be treated as payment errors."""
exc = Exception("Rate limit exceeded, try again in 2 seconds")
+57
View File
@@ -254,12 +254,51 @@ class TestClassifyApiError:
assert result.reason == FailoverReason.billing
assert result.retryable is False
def test_402_out_of_funds_billing(self):
e = MockAPIError(
"Payment Required",
status_code=402,
body={
"status": 402,
"message": (
"Your API key has run out of funds. Please go visit the "
"portal to sort that out: https://portal.nousresearch.com"
),
},
)
result = classify_api_error(e)
assert result.reason == FailoverReason.billing
assert result.retryable is False
def test_402_transient_usage_limit(self):
e = MockAPIError("usage limit exceeded, try again later", status_code=402)
result = classify_api_error(e)
assert result.reason == FailoverReason.rate_limit
assert result.retryable is True
def test_403_plan_entitlement_billing(self):
e = MockAPIError("This plan does not include the requested model", status_code=403)
result = classify_api_error(e)
assert result.reason == FailoverReason.billing
assert result.retryable is False
def test_404_free_tier_model_block_is_billing(self):
e = MockAPIError(
"Not Found",
status_code=404,
body={
"status": 404,
"message": (
"Model 'gpt-5' is not available on the Free Tier. "
"Upgrade at https://portal.nousresearch.com or pick a free model."
),
},
)
result = classify_api_error(e, provider="nous", model="gpt-5")
assert result.reason == FailoverReason.billing
assert result.retryable is False
assert result.should_fallback is True
# ── Rate limit ──
def test_429_rate_limit(self):
@@ -753,6 +792,19 @@ class TestClassifyApiError:
result = classify_api_error(e)
assert result.reason == FailoverReason.context_overflow
def test_error_code_model_not_supported_on_free_tier_is_billing(self):
e = MockAPIError(
"Model unavailable",
body={
"error": {
"code": "model_not_supported_on_free_tier",
"message": "Model 'gpt-5' is not available on the Free Tier.",
}
},
)
result = classify_api_error(e, provider="nous", model="gpt-5")
assert result.reason == FailoverReason.billing
# ── Message-only patterns (no status code) ──
def test_message_billing_pattern(self):
@@ -760,6 +812,11 @@ class TestClassifyApiError:
result = classify_api_error(e)
assert result.reason == FailoverReason.billing
def test_message_free_tier_model_block_is_billing(self):
e = Exception("Model 'gpt-5' is not available on the Free Tier.")
result = classify_api_error(e, provider="nous", model="gpt-5")
assert result.reason == FailoverReason.billing
def test_message_rate_limit_pattern(self):
e = Exception("rate limit reached for this model")
result = classify_api_error(e)
+46 -2
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"
+52 -23
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
+547
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
+31 -14
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: "")
+81
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
# ---------------------------------------------------------------------------
@@ -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
+22 -6
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 = []
@@ -489,6 +489,42 @@ class TestErrorResponseShapes:
assert "error" in result["results"][0]
assert result["results"][0]["url"] == "https://example.com"
def test_firecrawl_config_error_points_paid_users_to_nous_subscription(self, monkeypatch):
from plugins.web.firecrawl import provider as firecrawl_provider
monkeypatch.setattr(
"tools.web_tools.managed_nous_tools_enabled",
lambda: True,
raising=False,
)
with pytest.raises(ValueError) as exc_info:
firecrawl_provider._raise_web_backend_configuration_error()
message = str(exc_info.value)
assert "With your Nous subscription you can also use the Tool Gateway" in message
assert "select Nous Subscription as the web provider" in message
assert "managed Firecrawl web tools is unavailable" not in message
def test_firecrawl_config_error_uses_entitlement_message_when_not_paid(self, monkeypatch):
from plugins.web.firecrawl import provider as firecrawl_provider
monkeypatch.setattr(
"tools.web_tools.managed_nous_tools_enabled",
lambda: False,
raising=False,
)
monkeypatch.setattr(
"tools.web_tools.nous_tool_gateway_unavailable_message",
lambda capability: f"{capability} denied by test entitlement.",
raising=False,
)
with pytest.raises(ValueError) as exc_info:
firecrawl_provider._raise_web_backend_configuration_error()
assert "managed Firecrawl web tools denied by test entitlement" in str(exc_info.value)
def test_xai_search_returns_error_dict_when_unconfigured(self) -> None:
"""xAI returns a typed error dict (no XAI_API_KEY)."""
_ensure_plugins_loaded()
+27
View File
@@ -3875,6 +3875,33 @@ class TestNousCredentialRefresh:
assert "default_headers" not in rebuilt["kwargs"]
assert isinstance(agent.client, _RebuiltClient)
def test_try_refresh_nous_client_credentials_accepts_explicit_auth_mode(
self, agent, monkeypatch
):
agent.provider = "nous"
agent.api_mode = "chat_completions"
captured = {}
def _fake_resolve(**kwargs):
captured.update(kwargs)
return {
"api_key": "new-nous-key",
"base_url": "https://inference-api.nousresearch.com/v1",
}
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials", _fake_resolve
)
with patch("run_agent.OpenAI", return_value=MagicMock()):
ok = agent._try_refresh_nous_client_credentials(
force=False,
inference_auth_mode="legacy",
)
assert ok is True
assert captured["inference_auth_mode"] == "legacy"
class TestCredentialPoolRecovery:
def test_recover_with_pool_rotates_on_402(self, agent):
@@ -9,6 +9,8 @@ from unittest.mock import patch
import pytest
from hermes_cli.nous_account import NousPortalAccountInfo
REPO_ROOT = Path(__file__).resolve().parents[2]
TOOLS_DIR = REPO_ROOT / "tools"
@@ -69,10 +71,17 @@ def _enable_managed_nous_tools(monkeypatch):
The _install_fake_tools_package() helper resets and reimports tool modules,
so a simple monkeypatch on tool_backend_helpers doesn't survive. We patch
the *source* modules that the reimported modules will import from both
hermes_cli.auth and hermes_cli.models so the function body returns True.
hermes_cli.nous_account so the function body returns True.
"""
monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False)
monkeypatch.setattr(
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
def _install_fake_tools_package():
+11 -2
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import pytest
from hermes_cli.nous_account import NousPortalAccountInfo
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
@@ -48,8 +50,15 @@ def _restore_tool_and_agent_modules():
def _enable_managed_nous_tools(monkeypatch):
"""Patch the source modules so managed_nous_tools_enabled() returns True
even after tool modules are dynamically reloaded."""
monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False)
monkeypatch.setattr(
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
def _install_fake_tools_package():
+48 -15
View File
@@ -16,10 +16,12 @@ from unittest.mock import patch
import pytest
from hermes_cli.nous_account import NousPaidServiceAccessInfo, NousPortalAccountInfo
from tools.tool_backend_helpers import (
coerce_modal_mode,
has_direct_modal_credentials,
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
normalize_browser_cloud_provider,
normalize_modal_mode,
prefers_gateway,
@@ -40,42 +42,73 @@ class TestManagedNousToolsEnabled:
def test_disabled_when_not_logged_in(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {},
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(logged_in=False, source="none", fresh=False),
)
assert managed_nous_tools_enabled() is False
def test_disabled_for_free_tier(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.models.check_nous_free_tier",
lambda: True,
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=False,
),
)
assert managed_nous_tools_enabled() is False
def test_enabled_for_paid_subscriber(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.models.check_nous_free_tier",
lambda: False,
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
assert managed_nous_tools_enabled() is True
def test_returns_false_on_exception(self, monkeypatch):
"""Should never crash — returns False on any exception."""
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
"hermes_cli.nous_account.get_nous_portal_account_info",
_raise_import,
)
assert managed_nous_tools_enabled() is False
class TestNousToolGatewayUnavailableMessage:
def test_uses_entitlement_reason_for_logged_in_user(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda force_fresh=False: 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 = nous_tool_gateway_unavailable_message("managed image generation")
assert "credits are exhausted" in message
assert "managed image generation" in message
assert "https://portal.example.test/billing" in message
# ---------------------------------------------------------------------------
# normalize_browser_cloud_provider
# ---------------------------------------------------------------------------