diff --git a/hermes_cli/models.py b/hermes_cli/models.py index becfd96e418..1709bc2254b 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2370,11 +2370,16 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if api_key: live = _p.fetch_models(api_key=api_key) if live: - if normalized in {"kimi-coding", "kimi-coding-cn"}: - curated = list(_PROVIDER_MODELS.get(normalized, [])) - merged = list(curated) - merged_lower = {m.lower() for m in curated} - for m in live: + # Merge live API results with static curated list so + # models that the live endpoint omits (stale cache, + # partial rollout) still appear in the picker. + # Live entries come first (provider's preferred order), + # then curated-only entries are appended. (#46850) + curated = list(_PROVIDER_MODELS.get(normalized, [])) + if curated: + merged = list(live) + merged_lower = {m.lower() for m in live} + for m in curated: if m.lower() not in merged_lower: merged.append(m) merged_lower.add(m.lower()) diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index a9ffc8fb975..0eadbbb17d9 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -114,7 +114,8 @@ class TestProviderModelIdsPreferred: patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]), ): out = provider_model_ids("kimi-coding") - assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"] + # Live-first order; curated-only (k2.7-code) appended after live + assert out[:2] == ["kimi-k2.6", "kimi-k2.7-code"] def test_kimi_setup_flow_uses_same_coding_plan_catalog(self): """The setup wizard must not carry a stale duplicate Kimi model list.""" diff --git a/tests/hermes_cli/test_provider_live_curated_merge.py b/tests/hermes_cli/test_provider_live_curated_merge.py new file mode 100644 index 00000000000..02ca38a2c45 --- /dev/null +++ b/tests/hermes_cli/test_provider_live_curated_merge.py @@ -0,0 +1,113 @@ +"""Tests for live+curated merge in the generic profile-based provider path. + +Guards the fix for #46850: when a provider's live /v1/models endpoint +returns a stale or incomplete list, the static curated models from +``_PROVIDER_MODELS`` must still appear in the merged result. +""" + +from unittest.mock import MagicMock, patch + +from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids + + +class TestGenericProviderLiveCuratedMerge: + """provider_model_ids merges live + curated for generic api_key providers.""" + + def _make_profile(self, models=None): + """Create a minimal mock provider profile.""" + p = MagicMock() + p.auth_type = "api_key" + p.base_url = "https://api.example.com/v1" + p.fetch_models.return_value = models + p.fallback_models = None + return p + + def test_live_models_merged_with_curated(self): + """Live models come first; curated-only models are appended.""" + live = ["glm-5.2", "glm-5.1", "glm-5"] + curated = _PROVIDER_MODELS["zai"] # includes glm-5.1, glm-5, glm-4.5, etc. + profile = self._make_profile(live) + + with ( + patch("providers.get_provider_profile", return_value=profile), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}), + ): + result = provider_model_ids("zai") + + # Live entries first (in live order) + assert result[0] == "glm-5.2" + assert result[1] == "glm-5.1" + assert result[2] == "glm-5" + # Curated-only entries appended (e.g. glm-4.5) + result_lower = [m.lower() for m in result] + assert "glm-4.5" in result_lower + assert "glm-4.5-flash" in result_lower + + def test_no_duplicate_models(self): + """Models appearing in both live and curated are not duplicated.""" + live = ["glm-5.1", "glm-5"] + curated = ["glm-5.1", "glm-5", "glm-4.5"] + profile = self._make_profile(live) + + with ( + patch("providers.get_provider_profile", return_value=profile), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}), + patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}), + ): + result = provider_model_ids("zai") + + assert result.count("glm-5.1") == 1 + assert result.count("glm-5") == 1 + assert result == ["glm-5.1", "glm-5", "glm-4.5"] + + def test_case_insensitive_dedup(self): + """Dedup is case-insensitive but preserves first occurrence casing.""" + live = ["GLM-5.1", "glm-5"] + curated = ["glm-5.1", "GLM-5", "glm-4.5"] + profile = self._make_profile(live) + + with ( + patch("providers.get_provider_profile", return_value=profile), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}), + patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}), + ): + result = provider_model_ids("zai") + + # Live casing preserved for duplicates + assert result[0] == "GLM-5.1" + assert result[1] == "glm-5" + # Curated-only appended + assert "glm-4.5" in result + + def test_empty_curated_returns_live_only(self): + """When no curated list exists, live is returned as-is.""" + live = ["model-a", "model-b"] + profile = self._make_profile(live) + + with ( + patch("providers.get_provider_profile", return_value=profile), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}), + patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": []}), + ): + result = provider_model_ids("zai") + + assert result == ["model-a", "model-b"] + + def test_live_empty_falls_back_to_curated(self): + """When live returns nothing, curated static list is used. + + ZAI is in _MODELS_DEV_PREFERRED so the fallback path merges with + models.dev. We mock _merge_with_models_dev to isolate the test. + """ + curated = ["glm-5.1", "glm-5", "glm-4.5"] + profile = self._make_profile([]) + + with ( + patch("providers.get_provider_profile", return_value=profile), + patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}), + patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}), + patch("hermes_cli.models._merge_with_models_dev", return_value=curated), + ): + result = provider_model_ids("zai") + + assert result == curated