mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +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:
|
if api_key:
|
||||||
live = _p.fetch_models(api_key=api_key)
|
live = _p.fetch_models(api_key=api_key)
|
||||||
if live:
|
if live:
|
||||||
if normalized in {"kimi-coding", "kimi-coding-cn"}:
|
# Merge live API results with static curated list so
|
||||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
# models that the live endpoint omits (stale cache,
|
||||||
merged = list(curated)
|
# partial rollout) still appear in the picker.
|
||||||
merged_lower = {m.lower() for m in curated}
|
# Live entries come first (provider's preferred order),
|
||||||
for m in live:
|
# 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:
|
if m.lower() not in merged_lower:
|
||||||
merged.append(m)
|
merged.append(m)
|
||||||
merged_lower.add(m.lower())
|
merged_lower.add(m.lower())
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ class TestProviderModelIdsPreferred:
|
||||||
patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]),
|
patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]),
|
||||||
):
|
):
|
||||||
out = provider_model_ids("kimi-coding")
|
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):
|
def test_kimi_setup_flow_uses_same_coding_plan_catalog(self):
|
||||||
"""The setup wizard must not carry a stale duplicate Kimi model list."""
|
"""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