mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
fix(models): merge live API results with curated static catalog in generic provider path
When a provider's live /v1/models endpoint returns a stale or incomplete list (e.g. Z.AI missing glm-5.2), the generic profile-based code path returned only the live results, silently dropping curated models. Generalize the kimi-coding merge pattern to all providers: live entries come first (provider's preferred order), then curated-only entries are appended with case-insensitive dedup. This ensures models that the live endpoint omits still appear in /model picker. Fixes #46850
This commit is contained in:
parent
c6b0eb4de0
commit
630b43892d
3 changed files with 125 additions and 6 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
113
tests/hermes_cli/test_provider_live_curated_merge.py
Normal file
113
tests/hermes_cli/test_provider_live_curated_merge.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue