diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9edc505e3..5f13994c8 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -91,6 +91,7 @@ auxiliary_is_nous: bool = False # Default auxiliary models per provider _OPENROUTER_MODEL = "google/gemini-3-flash-preview" _NOUS_MODEL = "google/gemini-3-flash-preview" +_NOUS_FREE_TIER_VISION_MODEL = "xiaomi/mimo-v2-omni" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" @@ -720,7 +721,19 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]: global auxiliary_is_nous auxiliary_is_nous = True logger.debug("Auxiliary client: Nous Portal") - model = "gemini-3-flash" if nous.get("source") == "pool" else _NOUS_MODEL + if nous.get("source") == "pool": + model = "gemini-3-flash" + else: + model = _NOUS_MODEL + # Free-tier users can't use paid auxiliary models — use the free + # multimodal model instead so vision/browser-vision still works. + try: + from hermes_cli.models import check_nous_free_tier + if check_nous_free_tier(): + model = _NOUS_FREE_TIER_VISION_MODEL + logger.debug("Free-tier Nous account — using %s for auxiliary/vision", model) + except Exception: + pass return ( OpenAI( api_key=_nous_api_key(nous), diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 23119c661..9e92b450d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2279,14 +2279,21 @@ def _prompt_model_selection( model_ids: List[str], current_model: str = "", pricing: Optional[Dict[str, Dict[str, str]]] = None, + unavailable_models: Optional[List[str]] = None, + portal_url: str = "", ) -> Optional[str]: """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None. If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact price indicator is shown next to each model in aligned columns. + + If *unavailable_models* is provided, those models are shown grayed out + and unselectable, with an upgrade link to *portal_url*. """ from hermes_cli.models import _format_price_per_mtok + _unavailable = unavailable_models or [] + # Reorder: current model first, then the rest (deduplicated) ordered = [] if current_model and current_model in model_ids: @@ -2295,9 +2302,12 @@ def _prompt_model_selection( if mid not in ordered: ordered.append(mid) + # All models for column-width computation (selectable + unavailable) + all_models = list(ordered) + list(_unavailable) + # Column-aligned labels when pricing is available - has_pricing = bool(pricing and any(pricing.get(m) for m in ordered)) - name_col = max((len(m) for m in ordered), default=0) + 2 if has_pricing else 0 + has_pricing = bool(pricing and any(pricing.get(m) for m in all_models)) + name_col = max((len(m) for m in all_models), default=0) + 2 if has_pricing else 0 # Pre-compute formatted prices and dynamic column widths _price_cache: dict[str, tuple[str, str, str]] = {} @@ -2305,7 +2315,7 @@ def _prompt_model_selection( cache_col = 0 # only set if any model has cache pricing has_cache = False if has_pricing: - for mid in ordered: + for mid in all_models: p = pricing.get(mid) # type: ignore[union-attr] if p: inp = _format_price_per_mtok(p.get("prompt", "")) @@ -2350,12 +2360,35 @@ def _prompt_model_selection( header += f" {'Cache':>{cache_col}}" menu_title += header + " /Mtok" + # ANSI escape for dim text + _DIM = "\033[2m" + _RESET = "\033[0m" + # Try arrow-key menu first, fall back to number input try: from simple_term_menu import TerminalMenu + choices = [f" {_label(mid)}" for mid in ordered] choices.append(" Enter custom model name") choices.append(" Skip (keep current)") + + # Print the unavailable block BEFORE the menu via regular print(). + # simple_term_menu pads title lines to terminal width (causes wrapping), + # so we keep the title minimal and use stdout for the static block. + # clear_screen=False means our printed output stays visible above. + _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + if _unavailable: + print(menu_title) + print() + for mid in _unavailable: + print(f"{_DIM} {_label(mid)}{_RESET}") + print() + print(f"{_DIM} ── Upgrade at {_upgrade_url} for paid models ──{_RESET}") + print() + effective_title = "Available free models:" + else: + effective_title = menu_title + menu = TerminalMenu( choices, cursor_index=default_idx, @@ -2364,7 +2397,7 @@ def _prompt_model_selection( menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, - title=menu_title, + title=effective_title, ) idx = menu.show() if idx is None: @@ -2387,6 +2420,13 @@ def _prompt_model_selection( n = len(ordered) print(f" {n + 1:>{num_width}}. Enter custom model name") print(f" {n + 2:>{num_width}}. Skip (keep current)") + + if _unavailable: + _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + print() + print(f" {_DIM}── Unavailable models (requires paid tier — upgrade at {_upgrade_url}) ──{_RESET}") + for mid in _unavailable: + print(f" {'':>{num_width}} {_DIM}{_label(mid)}{_RESET}") print() while True: @@ -2821,16 +2861,37 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: code="invalid_token", ) - from hermes_cli.models import _PROVIDER_MODELS + from hermes_cli.models import ( + _PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models, + check_nous_free_tier, partition_nous_models_by_tier, + ) model_ids = _PROVIDER_MODELS.get("nous", []) print() + unavailable_models: list = [] + if model_ids: + pricing = get_pricing_for_provider("nous") + model_ids = filter_nous_free_models(model_ids, pricing) + free_tier = check_nous_free_tier() + if free_tier: + model_ids, unavailable_models = partition_nous_models_by_tier( + model_ids, pricing, free_tier=True, + ) + _portal = auth_state.get("portal_base_url", "") if model_ids: print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") - selected_model = _prompt_model_selection(model_ids) + selected_model = _prompt_model_selection( + model_ids, pricing=pricing, + unavailable_models=unavailable_models, + portal_url=_portal, + ) if selected_model: _save_model_choice(selected_model) print(f"Default model set to: {selected_model}") + elif unavailable_models: + _url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + print("No free models currently available.") + print(f"Upgrade at {_url} to access paid models.") else: print("No curated models available for Nous Portal.") except Exception as exc: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 55faf8413..dae8cc958 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1195,14 +1195,15 @@ def _model_flow_nous(config, current_model="", args=None): # Already logged in — use curated model list (same as OpenRouter defaults). # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. - from hermes_cli.models import _PROVIDER_MODELS, get_pricing_for_provider + from hermes_cli.models import ( + _PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models, + check_nous_free_tier, partition_nous_models_by_tier, + ) model_ids = _PROVIDER_MODELS.get("nous", []) if not model_ids: print("No curated models available for Nous Portal.") return - print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") - # Verify credentials are still valid (catches expired sessions early) try: creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) @@ -1228,7 +1229,44 @@ def _model_flow_nous(config, current_model="", args=None): # Fetch live pricing (non-blocking — returns empty dict on failure) pricing = get_pricing_for_provider("nous") - selected = _prompt_model_selection(model_ids, current_model=current_model, pricing=pricing) + # Check if user is on free tier + free_tier = check_nous_free_tier() + + # For both tiers: apply the allowlist filter first (removes non-allowlisted + # free models and allowlist models that aren't actually free). + # Then for free users: partition remaining models into selectable/unavailable. + model_ids = filter_nous_free_models(model_ids, pricing) + unavailable_models: list[str] = [] + if free_tier: + model_ids, unavailable_models = partition_nous_models_by_tier(model_ids, pricing, free_tier=True) + + if not model_ids and not unavailable_models: + print("No models available for Nous Portal after filtering.") + return + + # Resolve portal URL for upgrade links (may differ on staging) + _nous_portal_url = "" + try: + _nous_state = get_provider_auth_state("nous") + if _nous_state: + _nous_portal_url = _nous_state.get("portal_base_url", "") + except Exception: + pass + + if free_tier and not model_ids: + print("No free models currently available.") + if unavailable_models: + from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL + _url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + print(f"Upgrade at {_url} to access paid models.") + return + + print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") + + selected = _prompt_model_selection( + model_ids, current_model=current_model, pricing=pricing, + unavailable_models=unavailable_models, portal_url=_nous_portal_url, + ) if selected: _save_model_choice(selected) # Reactivate Nous as the provider and update config diff --git a/hermes_cli/models.py b/hermes_cli/models.py index a5b1c2b2f..85413267d 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -265,6 +265,202 @@ _PROVIDER_MODELS: dict[str, list[str]] = { ], } +# --------------------------------------------------------------------------- +# Nous Portal free-model filtering +# --------------------------------------------------------------------------- +# Models that are ALLOWED to appear when priced as free on Nous Portal. +# Any other free model is hidden — prevents promotional/temporary free models +# from cluttering the selection when users are paying subscribers. +# Models in this list are ALSO filtered out if they are NOT free (i.e. they +# should only appear in the menu when they are genuinely free). +_NOUS_ALLOWED_FREE_MODELS: frozenset[str] = frozenset({ + "xiaomi/mimo-v2-pro", + "xiaomi/mimo-v2-omni", +}) + + +def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: + """Return True if *model_id* has zero-cost prompt AND completion pricing.""" + p = pricing.get(model_id) + if not p: + return False + try: + return float(p.get("prompt", "1")) == 0 and float(p.get("completion", "1")) == 0 + except (TypeError, ValueError): + return False + + +def filter_nous_free_models( + model_ids: list[str], + pricing: dict[str, dict[str, str]], +) -> list[str]: + """Filter the Nous Portal model list according to free-model policy. + + Rules: + • Paid models that are NOT in the allowlist → keep (normal case). + • Free models that are NOT in the allowlist → drop. + • Allowlist models that ARE free → keep. + • Allowlist models that are NOT free → drop. + """ + if not pricing: + return model_ids # no pricing data — can't filter, show everything + + result: list[str] = [] + for mid in model_ids: + free = _is_model_free(mid, pricing) + if mid in _NOUS_ALLOWED_FREE_MODELS: + # Allowlist model: only show when it's actually free + if free: + result.append(mid) + else: + # Regular model: keep only when it's NOT free + if not free: + result.append(mid) + return result + + +# --------------------------------------------------------------------------- +# Nous Portal account tier detection +# --------------------------------------------------------------------------- + +def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dict[str, Any]: + """Fetch the user's Nous Portal account/subscription info. + + Calls ``/api/oauth/account`` with the OAuth access token. + + Returns the parsed JSON dict on success, e.g.:: + + { + "subscription": { + "plan": "Plus", + "tier": 2, + "monthly_charge": 20, + "credits_remaining": 1686.60, + ... + }, + ... + } + + Returns an empty dict on any failure (network, auth, parse). + """ + base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/") + url = f"{base}/api/oauth/account" + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode()) + except Exception: + return {} + + +def is_nous_free_tier(account_info: dict[str, Any]) -> bool: + """Return True if the account info indicates a free (unpaid) tier. + + Checks ``subscription.monthly_charge == 0``. Returns False when + the field is missing or unparseable (assumes paid — don't block users). + """ + sub = account_info.get("subscription") + if not isinstance(sub, dict): + return False + charge = sub.get("monthly_charge") + if charge is None: + return False + try: + return float(charge) == 0 + except (TypeError, ValueError): + return False + + +def partition_nous_models_by_tier( + model_ids: list[str], + pricing: dict[str, dict[str, str]], + free_tier: bool, +) -> tuple[list[str], list[str]]: + """Split Nous models into (selectable, unavailable) based on user tier. + + For paid-tier users: all models are selectable, none unavailable + (free-model filtering is handled separately by ``filter_nous_free_models``). + + For free-tier users: only free models are selectable; paid models + are returned as unavailable (shown grayed out in the menu). + """ + if not free_tier: + return (model_ids, []) + + if not pricing: + return (model_ids, []) # can't determine, show everything + + selectable: list[str] = [] + unavailable: list[str] = [] + for mid in model_ids: + if _is_model_free(mid, pricing): + selectable.append(mid) + else: + unavailable.append(mid) + return (selectable, unavailable) + + +# --------------------------------------------------------------------------- +# TTL cache for free-tier detection — avoids repeated API calls within a +# session while still picking up upgrades quickly. +# --------------------------------------------------------------------------- +_FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes) +_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp) + + +def clear_nous_free_tier_cache() -> None: + """Invalidate the cached free-tier result (e.g. after login/logout).""" + global _free_tier_cache + _free_tier_cache = None + + +def check_nous_free_tier() -> bool: + """Check if the current Nous Portal user is on a free (unpaid) tier. + + Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid + hitting the Portal API on every call. The cache is short-lived so + that an account upgrade is reflected within a few minutes. + + Returns False (assume paid) on any error — never blocks paying users. + """ + global _free_tier_cache + import time + + now = time.monotonic() + if _free_tier_cache is not None: + cached_result, cached_at = _free_tier_cache + if now - cached_at < _FREE_TIER_CACHE_TTL: + return cached_result + + try: + from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials + + # Ensure we have a fresh token (triggers refresh if needed) + resolve_nous_runtime_credentials(min_key_ttl_seconds=60) + + state = get_provider_auth_state("nous") + if not state: + _free_tier_cache = (False, now) + return False + access_token = state.get("access_token", "") + portal_url = state.get("portal_base_url", "") + if not access_token: + _free_tier_cache = (False, now) + return False + + account_info = fetch_nous_account_tier(access_token, portal_url) + result = is_nous_free_tier(account_info) + _free_tier_cache = (result, now) + return result + except Exception: + _free_tier_cache = (False, now) + return False # default to paid on error — don't block users + + _PROVIDER_LABELS = { "openrouter": "OpenRouter", "openai-codex": "OpenAI Codex", diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 74f844245..776256f0f 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -1,6 +1,15 @@ """Tests for the hermes_cli models module.""" -from hermes_cli.models import OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model +from unittest.mock import patch, MagicMock + +from hermes_cli.models import ( + OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model, + filter_nous_free_models, _NOUS_ALLOWED_FREE_MODELS, + is_nous_free_tier, partition_nous_models_by_tier, + check_nous_free_tier, clear_nous_free_tier_cache, + _FREE_TIER_CACHE_TTL, +) +import hermes_cli.models as _models_mod class TestModelIds: @@ -124,3 +133,226 @@ class TestDetectProviderForModel: result = detect_provider_for_model("claude-opus-4-6", "openai-codex") assert result is not None assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested + + +class TestFilterNousFreeModels: + """Tests for filter_nous_free_models — Nous Portal free-model policy.""" + + _PAID = {"prompt": "0.000003", "completion": "0.000015"} + _FREE = {"prompt": "0", "completion": "0"} + + def test_paid_models_kept(self): + """Regular paid models pass through unchanged.""" + models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"] + pricing = {m: self._PAID for m in models} + assert filter_nous_free_models(models, pricing) == models + + def test_free_non_allowlist_models_removed(self): + """Free models NOT in the allowlist are filtered out.""" + models = ["anthropic/claude-opus-4.6", "arcee-ai/trinity-large-preview:free"] + pricing = { + "anthropic/claude-opus-4.6": self._PAID, + "arcee-ai/trinity-large-preview:free": self._FREE, + } + result = filter_nous_free_models(models, pricing) + assert result == ["anthropic/claude-opus-4.6"] + + def test_allowlist_model_kept_when_free(self): + """Allowlist models are kept when they report as free.""" + models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro"] + pricing = { + "anthropic/claude-opus-4.6": self._PAID, + "xiaomi/mimo-v2-pro": self._FREE, + } + result = filter_nous_free_models(models, pricing) + assert result == ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro"] + + def test_allowlist_model_removed_when_paid(self): + """Allowlist models are removed when they are NOT free.""" + models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro"] + pricing = { + "anthropic/claude-opus-4.6": self._PAID, + "xiaomi/mimo-v2-pro": self._PAID, + } + result = filter_nous_free_models(models, pricing) + assert result == ["anthropic/claude-opus-4.6"] + + def test_no_pricing_returns_all(self): + """When pricing data is unavailable, all models pass through.""" + models = ["anthropic/claude-opus-4.6", "nvidia/nemotron-3-super-120b-a12b:free"] + assert filter_nous_free_models(models, {}) == models + + def test_model_with_no_pricing_entry_treated_as_paid(self): + """A model missing from the pricing dict is kept (assumed paid).""" + models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} # gpt-5.4 not in pricing + result = filter_nous_free_models(models, pricing) + assert result == models + + def test_mixed_scenario(self): + """End-to-end: mix of paid, free-allowed, free-disallowed, allowlist-not-free.""" + models = [ + "anthropic/claude-opus-4.6", # paid, not allowlist → keep + "nvidia/nemotron-3-super-120b-a12b:free", # free, not allowlist → drop + "xiaomi/mimo-v2-pro", # free, allowlist → keep + "xiaomi/mimo-v2-omni", # paid, allowlist → drop + "openai/gpt-5.4", # paid, not allowlist → keep + ] + pricing = { + "anthropic/claude-opus-4.6": self._PAID, + "nvidia/nemotron-3-super-120b-a12b:free": self._FREE, + "xiaomi/mimo-v2-pro": self._FREE, + "xiaomi/mimo-v2-omni": self._PAID, + "openai/gpt-5.4": self._PAID, + } + result = filter_nous_free_models(models, pricing) + assert result == [ + "anthropic/claude-opus-4.6", + "xiaomi/mimo-v2-pro", + "openai/gpt-5.4", + ] + + def test_allowlist_contains_expected_models(self): + """Sanity: the allowlist has the models we expect.""" + assert "xiaomi/mimo-v2-pro" in _NOUS_ALLOWED_FREE_MODELS + assert "xiaomi/mimo-v2-omni" in _NOUS_ALLOWED_FREE_MODELS + + +class TestIsNousFreeTier: + """Tests for is_nous_free_tier — account tier detection.""" + + def test_paid_plus_tier(self): + assert is_nous_free_tier({"subscription": {"plan": "Plus", "tier": 2, "monthly_charge": 20}}) is False + + def test_free_tier_by_charge(self): + assert is_nous_free_tier({"subscription": {"plan": "Free", "tier": 0, "monthly_charge": 0}}) is True + + def test_no_charge_field_not_free(self): + """Missing monthly_charge defaults to not-free (don't block users).""" + assert is_nous_free_tier({"subscription": {"plan": "Free", "tier": 0}}) is False + + def test_plan_name_alone_not_free(self): + """Plan name alone is not enough — monthly_charge is required.""" + assert is_nous_free_tier({"subscription": {"plan": "free"}}) is False + + def test_empty_subscription_not_free(self): + """Empty subscription dict defaults to not-free (don't block users).""" + assert is_nous_free_tier({"subscription": {}}) is False + + def test_no_subscription_not_free(self): + """Missing subscription key returns False.""" + assert is_nous_free_tier({}) is False + + def test_empty_response_not_free(self): + """Completely empty response defaults to not-free.""" + assert is_nous_free_tier({}) is False + + +class TestPartitionNousModelsByTier: + """Tests for partition_nous_models_by_tier — free vs paid tier model split.""" + + _PAID = {"prompt": "0.000003", "completion": "0.000015"} + _FREE = {"prompt": "0", "completion": "0"} + + def test_paid_tier_all_selectable(self): + """Paid users get all models as selectable, none unavailable.""" + models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro"] + pricing = {"anthropic/claude-opus-4.6": self._PAID, "xiaomi/mimo-v2-pro": self._FREE} + sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=False) + assert sel == models + assert unav == [] + + def test_free_tier_splits_correctly(self): + """Free users see only free models; paid ones are unavailable.""" + models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro", "openai/gpt-5.4"] + pricing = { + "anthropic/claude-opus-4.6": self._PAID, + "xiaomi/mimo-v2-pro": self._FREE, + "openai/gpt-5.4": self._PAID, + } + sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True) + assert sel == ["xiaomi/mimo-v2-pro"] + assert unav == ["anthropic/claude-opus-4.6", "openai/gpt-5.4"] + + def test_no_pricing_returns_all(self): + """Without pricing data, all models are selectable.""" + models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"] + sel, unav = partition_nous_models_by_tier(models, {}, free_tier=True) + assert sel == models + assert unav == [] + + def test_all_free_models(self): + """When all models are free, free-tier users can select all.""" + models = ["xiaomi/mimo-v2-pro", "xiaomi/mimo-v2-omni"] + pricing = {m: self._FREE for m in models} + sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True) + assert sel == models + assert unav == [] + + def test_all_paid_models(self): + """When all models are paid, free-tier users have none selectable.""" + models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"] + pricing = {m: self._PAID for m in models} + sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True) + assert sel == [] + assert unav == models + + +class TestCheckNousFreeTierCache: + """Tests for the TTL cache on check_nous_free_tier().""" + + def setup_method(self): + """Reset cache before each test.""" + clear_nous_free_tier_cache() + + def teardown_method(self): + """Reset cache after each test.""" + clear_nous_free_tier_cache() + + @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() + + assert result1 is True + assert result2 is True + # fetch_nous_account_tier should only be called once (cached on second call) + assert mock_fetch.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 + + # Simulate TTL expiry by backdating the cache timestamp + 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 + + assert result1 is False + assert result2 is False + + def test_clear_cache_forces_refresh(self): + """clear_nous_free_tier_cache() invalidates the cached result.""" + # Manually seed the cache + import time + _models_mod._free_tier_cache = (True, time.monotonic()) + + clear_nous_free_tier_cache() + assert _models_mod._free_tier_cache is None + + def test_cache_ttl_is_short(self): + """TTL should be short enough to catch upgrades quickly (<=5 min).""" + assert _FREE_TIER_CACHE_TTL <= 300