diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index ac102d0be76..90d6a639358 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5271,6 +5271,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, union_with_portal_free_recommendations, + union_with_portal_paid_recommendations, ) model_ids = get_curated_nous_model_ids() @@ -5279,19 +5280,27 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: if model_ids: pricing = get_pricing_for_provider("nous") free_tier = check_nous_free_tier() + _portal_for_recs = auth_state.get("portal_base_url", "") if free_tier: # The Portal's freeRecommendedModels endpoint is the # source of truth for what's free *right now*. Augment # the curated list with anything new the Portal flags # as free so users on older Hermes builds still see # newly-launched free models without a CLI release. - _portal_for_recs = auth_state.get("portal_base_url", "") model_ids, pricing = union_with_portal_free_recommendations( model_ids, pricing, _portal_for_recs, ) model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, pricing, free_tier=True, ) + else: + # Paid-tier mirror: pull paidRecommendedModels so newly + # launched paid models surface in the picker even if + # the in-repo curated list and docs-hosted manifest + # haven't caught up yet. + model_ids, pricing = union_with_portal_paid_recommendations( + model_ids, pricing, _portal_for_recs, + ) _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.") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 33f915a9e6b..7a30a57ca77 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2590,6 +2590,7 @@ def _model_flow_nous(config, current_model="", args=None): check_nous_free_tier, partition_nous_models_by_tier, union_with_portal_free_recommendations, + union_with_portal_paid_recommendations, ) model_ids = get_curated_nous_model_ids() @@ -2645,6 +2646,10 @@ def _model_flow_nous(config, current_model="", args=None): # with the Portal's freeRecommendedModels list so newly-launched free # models show up even if this CLI build's hardcoded curated list and # docs-hosted manifest haven't caught up yet. + # + # For paid users: mirror the same idea with paidRecommendedModels so + # newly-launched paid models surface in the picker too — independent + # of CLI release cadence. unavailable_models: list[str] = [] if free_tier: model_ids, pricing = union_with_portal_free_recommendations( @@ -2653,6 +2658,10 @@ def _model_flow_nous(config, current_model="", args=None): model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, pricing, free_tier=True ) + else: + model_ids, pricing = union_with_portal_paid_recommendations( + model_ids, pricing, _nous_portal_url, + ) if not model_ids and not unavailable_models: print("No models available for Nous Portal after filtering.") diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 813045dfd04..5f355d03b99 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -621,6 +621,71 @@ def union_with_portal_free_recommendations( return (augmented_ids, augmented_pricing) +def union_with_portal_paid_recommendations( + curated_ids: list[str], + pricing: dict[str, dict[str, str]], + portal_base_url: str = "", + *, + force_refresh: bool = False, +) -> tuple[list[str], dict[str, dict[str, str]]]: + """Augment curated list with the Portal's ``paidRecommendedModels``. + + Mirror of :func:`union_with_portal_free_recommendations` for paid-tier + users. The Portal's ``/api/nous/recommended-models`` endpoint advertises + which paid models are blessed *right now* — independent of what the + in-repo ``_PROVIDER_MODELS["nous"]`` list happens to contain or whether + the docs-hosted catalog manifest has been rebuilt since the last release. + + For paid-tier users this lets newly-launched paid models surface in the + picker even if the user is running an older Hermes that doesn't ship + them in its hardcoded curated list. This function returns an augmented + ``(model_ids, pricing)`` pair where: + + * Portal paid recommendations missing from ``curated_ids`` are + appended at the front (so the picker shows them first). + * ``pricing`` is left untouched — we deliberately do NOT synthesize + pricing entries for paid models. Live pricing is fetched separately + via :func:`get_pricing_for_provider`; if the live endpoint hasn't + published pricing yet, the picker shows a blank price column rather + than fabricating numbers. (The free helper synthesizes ``$0`` so + :func:`partition_nous_models_by_tier` keeps free models selectable; + no equivalent gating applies on the paid side, so synthesis would + only mislead the user.) + + Failures (network, parse, missing field) are silent and degrade to + returning the inputs unchanged — never block the picker on a + Portal-side hiccup. + """ + try: + payload = fetch_nous_recommended_models( + portal_base_url, force_refresh=force_refresh + ) + except Exception: + return (list(curated_ids), dict(pricing)) + + paid_block = payload.get("paidRecommendedModels") if isinstance(payload, dict) else None + if not isinstance(paid_block, list) or not paid_block: + return (list(curated_ids), dict(pricing)) + + portal_paid_ids: list[str] = [] + for entry in paid_block: + name = _extract_model_name(entry) + if name: + portal_paid_ids.append(name) + if not portal_paid_ids: + return (list(curated_ids), dict(pricing)) + + augmented_ids = list(curated_ids) + seen = set(augmented_ids) + # Prepend Portal paid recommendations that aren't already curated, so + # the Portal-blessed picks surface first in the picker. + new_ones = [mid for mid in portal_paid_ids if mid not in seen] + if new_ones: + augmented_ids = new_ones + augmented_ids + + return (augmented_ids, dict(pricing)) + + # --------------------------------------------------------------------------- # TTL cache for free-tier detection — avoids repeated API calls within a # session while still picking up upgrades quickly. diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 668105bf10d..8ccf5b57f2d 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -7,6 +7,7 @@ from hermes_cli.models import ( is_nous_free_tier, partition_nous_models_by_tier, check_nous_free_tier, _FREE_TIER_CACHE_TTL, union_with_portal_free_recommendations, + union_with_portal_paid_recommendations, ) import hermes_cli.models as _models_mod @@ -506,6 +507,147 @@ class TestUnionWithPortalFreeRecommendations: assert p["qwen/qwen3.6-plus"] == self._FREE +class TestUnionWithPortalPaidRecommendations: + """Tests for union_with_portal_paid_recommendations. + + Mirror of TestUnionWithPortalFreeRecommendations: the Portal's + paidRecommendedModels endpoint is the source of truth for what's a + blessed paid model *right now*. The in-repo curated list and + docs-hosted manifest can lag — this helper guarantees newly-launched + paid models surface in the picker for paid-tier users without a CLI + release. + """ + + _PAID = {"prompt": "0.000003", "completion": "0.000015"} + _FREE = {"prompt": "0", "completion": "0"} + + def _payload(self, paid_models: list[str]) -> dict: + return { + "paidRecommendedModels": [ + {"modelName": mid, "displayName": mid} for mid in paid_models + ], + } + + def test_adds_portal_paid_model_missing_from_curated(self): + """A Portal-advertised paid model not in curated is prepended.""" + curated = ["anthropic/claude-opus-4.6"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["openai/gpt-5.4"]), + ): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + + assert ids[0] == "openai/gpt-5.4" # prepended + assert "anthropic/claude-opus-4.6" in ids + # Existing pricing untouched + assert p["anthropic/claude-opus-4.6"] == self._PAID + + def test_does_not_synthesize_pricing_for_paid_models(self): + """Paid recommendations missing from live pricing get no synthetic entry. + + Synthesizing zero pricing (like the free helper does) would mislead + :func:`partition_nous_models_by_tier` into treating them as free; + synthesizing a non-zero placeholder would lie to the user. The + right thing is to leave pricing absent so the picker shows a blank + column until the live pricing endpoint catches up. + """ + curated = ["anthropic/claude-opus-4.6"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["openai/gpt-5.4"]), + ): + _, p = union_with_portal_paid_recommendations(curated, pricing, "") + + assert "openai/gpt-5.4" not in p + assert p["anthropic/claude-opus-4.6"] == self._PAID + + def test_does_not_duplicate_curated_entries(self): + """A Portal paid model already in curated is not duplicated.""" + curated = ["openai/gpt-5.4", "anthropic/claude-opus-4.6"] + pricing = { + "openai/gpt-5.4": self._PAID, + "anthropic/claude-opus-4.6": self._PAID, + } + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["openai/gpt-5.4"]), + ): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + + assert ids == curated + assert p == pricing + + def test_empty_payload_returns_inputs_unchanged(self): + """Empty Portal response leaves curated + pricing untouched.""" + curated = ["a", "b"] + pricing = {"a": self._PAID} + with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_missing_paidRecommendedModels_key(self): + """Portal payload without paidRecommendedModels degrades gracefully.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value={"freeRecommendedModels": [{"modelName": "x"}]}, + ): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_fetch_failure_returns_inputs(self): + """Network failures don't blow up the picker.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + side_effect=RuntimeError("network down"), + ): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + assert ids == curated + assert p == pricing + + def test_invalid_entries_skipped(self): + """Non-dict / missing-modelName entries are filtered out.""" + curated = ["a"] + pricing = {"a": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value={ + "paidRecommendedModels": [ + "not-a-dict", + {"displayName": "no-modelName"}, + {"modelName": ""}, + {"modelName": "openai/gpt-5.4"}, + ] + }, + ): + ids, p = union_with_portal_paid_recommendations(curated, pricing, "") + assert ids == ["openai/gpt-5.4", "a"] + # No synthetic entry — pricing is untouched. + assert "openai/gpt-5.4" not in p + + def test_preserves_relative_order_of_new_paid_models(self): + """Multiple new paid models are prepended in payload order.""" + curated = ["anthropic/claude-opus-4.6"] + pricing = {"anthropic/claude-opus-4.6": self._PAID} + with patch( + "hermes_cli.models.fetch_nous_recommended_models", + return_value=self._payload(["openai/gpt-5.4", "openai/gpt-5.5"]), + ): + ids, _ = union_with_portal_paid_recommendations(curated, pricing, "") + assert ids == [ + "openai/gpt-5.4", + "openai/gpt-5.5", + "anthropic/claude-opus-4.6", + ] + + class TestCheckNousFreeTierCache: """Tests for the TTL cache on check_nous_free_tier()."""