diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c82bad3f0..6c0af25d3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3375,7 +3375,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: ) from hermes_cli.models import ( - _PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models, + _PROVIDER_MODELS, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, ) model_ids = _PROVIDER_MODELS.get("nous", []) @@ -3384,7 +3384,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: 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( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1da3fcbbe..fe2fdd378 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2165,7 +2165,6 @@ def _model_flow_nous(config, current_model="", args=None): from hermes_cli.models import ( _PROVIDER_MODELS, get_pricing_for_provider, - filter_nous_free_models, check_nous_free_tier, partition_nous_models_by_tier, ) @@ -2208,10 +2207,8 @@ def _model_flow_nous(config, current_model="", args=None): # 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) + # For free users: partition models into selectable/unavailable based on + # whether they are free per the Portal-reported pricing. unavailable_models: list[str] = [] if free_tier: model_ids, unavailable_models = partition_nous_models_by_tier( diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 33614d426..67c73ff83 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -362,17 +362,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = { _PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS] # --------------------------------------------------------------------------- -# Nous Portal free-model filtering +# Nous Portal free-model helper # --------------------------------------------------------------------------- -# 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", -}) +# The Nous Portal models endpoint is the source of truth for which models +# are currently offered (free or paid). We trust whatever it returns and +# surface it to users as-is — no local allowlist filtering. def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: @@ -386,35 +380,6 @@ def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: 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 # --------------------------------------------------------------------------- @@ -478,8 +443,7 @@ def partition_nous_models_by_tier( ) -> 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 paid-tier users: all models are selectable, none unavailable. For free-tier users: only free models are selectable; paid models are returned as unavailable (shown grayed out in the menu). diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 3a58282ca..b6d70a26f 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -376,7 +376,6 @@ class TestLoginNousSkipKeepsCurrent: lambda *a, **kw: prompt_returns, ) monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda p: {}) - monkeypatch.setattr(models_mod, "filter_nous_free_models", lambda ids, p: ids) monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda: None) monkeypatch.setattr( models_mod, "partition_nous_models_by_tier", diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index ea2f3057f..f3b66ed5e 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -4,7 +4,6 @@ from unittest.mock import patch, MagicMock from hermes_cli.models import ( OPENROUTER_MODELS, fetch_openrouter_models, 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, _FREE_TIER_CACHE_TTL, ) @@ -293,89 +292,6 @@ class TestDetectProviderForModel: 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."""