feat(nous): free-tier model gating, pricing display, and vision fallback
- Show pricing during initial Nous Portal login (was missing from _login_nous, only shown in the already-logged-in hermes model path) - Filter free models for paid subscribers: non-allowlisted free models are hidden; allowlisted models (xiaomi/mimo-v2-pro, xiaomi/mimo-v2-omni) only appear when actually priced as free - Detect free-tier accounts via portal api/oauth/account endpoint (monthly_charge == 0); free-tier users see only free models as selectable, with paid models shown dimmed and unselectable - Use xiaomi/mimo-v2-omni as the auxiliary vision model for free-tier Nous users so vision_analyze and browser_vision work without paid model access (replaces the default google/gemini-3-flash-preview) - Unavailable models rendered via print() before TerminalMenu to avoid simple_term_menu line-width padding artifacts; upgrade URL resolved from auth state portal_base_url (supports staging/custom portals) - Add 21 tests covering filter_nous_free_models, is_nous_free_tier, and partition_nous_models_by_tier
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
"""Tests for the hermes_cli models module."""
|
||||
|
||||
from hermes_cli.models import OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class TestModelIds:
|
||||
@@ -124,3 +128,166 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user