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:
liuhao1024 2026-06-16 04:06:29 +08:00
parent c6b0eb4de0
commit 630b43892d
3 changed files with 125 additions and 6 deletions

View file

@ -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())

View file

@ -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."""

View 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