From ee7b8a467297cb46487eeecd554090bd7d36a268 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 16 Jun 2026 16:24:11 +0800 Subject: [PATCH] fix(models): validate_requested_model falls back to curated catalog when live API omits model When live /v1/models responds but omits a model that exists in the curated static catalog, validate_requested_model now accepts it with a note instead of rejecting. This covers the /model slash-command path (the picker path was already fixed in the parent commit). Addresses review feedback from potatogim on #46857. --- hermes_cli/models.py | 22 ++++++++ .../test_provider_live_curated_merge.py | 52 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 1709bc2254b..3432f1ae4a1 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3939,6 +3939,28 @@ def validate_requested_model( if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) + # Model not in live /v1/models — check the curated catalog + # before rejecting. Providers may omit models from their live + # listing that are still valid (stale cache, partial rollout, + # gated previews). If the curated list has it, accept with a + # note. (#46850) + try: + curated = provider_model_ids(normalized) + except Exception: + curated = [] + if curated: + curated_lower = {m.lower(): m for m in curated} + if requested_for_lookup.lower() in curated_lower: + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": ( + f"Note: `{requested}` was not found in the live /v1/models listing " + f"but exists in the curated catalog — accepted." + ), + } + return { "accepted": False, "persist": False, diff --git a/tests/hermes_cli/test_provider_live_curated_merge.py b/tests/hermes_cli/test_provider_live_curated_merge.py index 02ca38a2c45..28f35439af7 100644 --- a/tests/hermes_cli/test_provider_live_curated_merge.py +++ b/tests/hermes_cli/test_provider_live_curated_merge.py @@ -111,3 +111,55 @@ class TestGenericProviderLiveCuratedMerge: result = provider_model_ids("zai") assert result == curated + + +class TestValidateRequestedModelCuratedFallback: + """validate_requested_model falls back to curated catalog when live API omits model.""" + + def test_model_in_curated_but_not_live_is_accepted(self): + """When live /v1/models omits a model that exists in the curated + catalog, validate_requested_model should accept it with a note.""" + from hermes_cli.models import validate_requested_model + + # Live API returns only glm-5.1, but curated has glm-5.2 + live_models = ["glm-5.1"] + curated = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.5"] + + with ( + patch("hermes_cli.models.fetch_api_models", return_value=live_models), + patch("hermes_cli.models.provider_model_ids", return_value=curated), + ): + result = validate_requested_model("glm-5.2", "zai", api_key="dummy") + + assert result["accepted"] is True + assert result["recognized"] is True + assert result["message"] is not None + assert "curated catalog" in result["message"] + + def test_model_not_in_curated_nor_live_is_rejected(self): + """When a model is in neither live nor curated, it should be rejected.""" + from hermes_cli.models import validate_requested_model + + live_models = ["glm-5.1"] + curated = ["glm-5.1", "glm-5", "glm-4.5"] + + with ( + patch("hermes_cli.models.fetch_api_models", return_value=live_models), + patch("hermes_cli.models.provider_model_ids", return_value=curated), + ): + result = validate_requested_model("nonexistent-model", "zai", api_key="dummy") + + assert result["accepted"] is False + + def test_model_in_live_is_accepted_without_curated_check(self): + """When the model is in the live API, it should be accepted directly.""" + from hermes_cli.models import validate_requested_model + + live_models = ["glm-5.1", "glm-5"] + + with patch("hermes_cli.models.fetch_api_models", return_value=live_models): + result = validate_requested_model("glm-5.1", "zai", api_key="dummy") + + assert result["accepted"] is True + assert result["recognized"] is True + assert result["message"] is None