diff --git a/hermes_cli/models.py b/hermes_cli/models.py index d30d2ecf7..aa6996a48 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2220,7 +2220,20 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if normalized == "anthropic": live = _fetch_anthropic_models() if live: - return live + # The live /v1/models dump lags newly-routed curated aliases + # (e.g. claude-fable-5, which is reachable on Anthropic before it + # is enumerated by the models endpoint). Surface curated entries + # first, then append any live-only models, so a fresh curated + # model never disappears just because the API hasn't listed it yet. + curated = list(_PROVIDER_MODELS.get("anthropic", [])) + merged = list(curated) + merged_lower = {m.lower() for m in curated} + for m in live: + if m.lower() not in merged_lower: + merged.append(m) + merged_lower.add(m.lower()) + return merged + return list(_PROVIDER_MODELS.get("anthropic", [])) if normalized == "ollama-cloud": live = fetch_ollama_cloud_models(force_refresh=force_refresh) if live: diff --git a/tests/hermes_cli/test_anthropic_picker_curated.py b/tests/hermes_cli/test_anthropic_picker_curated.py new file mode 100644 index 000000000..407bb362b --- /dev/null +++ b/tests/hermes_cli/test_anthropic_picker_curated.py @@ -0,0 +1,58 @@ +"""Regression tests for the Anthropic model-picker dropping curated aliases. + +Bug — newly-routed curated aliases vanished on a native Anthropic setup + ``provider_model_ids("anthropic")`` returned the live ``/v1/models`` dump + verbatim whenever Anthropic credentials were configured. Anthropic's API + lags behind freshly-routed aliases (e.g. ``claude-fable-5``, which is + reachable on Anthropic before the models endpoint enumerates it), so the + curated entry disappeared from the picker. The picker now merges the + curated ``_PROVIDER_MODELS["anthropic"]`` list with the live catalog — + curated entries first, live-only models appended, deduped — mirroring the + OpenAI curated-merge philosophy. +""" + +from unittest.mock import patch + +from hermes_cli import models as M + + +def test_anthropic_curated_alias_survives_when_live_omits_it(): + """A curated alias missing from /v1/models still surfaces (first).""" + curated = M._PROVIDER_MODELS["anthropic"] + assert "claude-fable-5" in curated # sanity: the alias is curated + + # Live catalog the API would actually return — no fable-5. + live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"] + with patch.object(M, "_fetch_anthropic_models", return_value=live): + result = M.provider_model_ids("anthropic") + + assert "claude-fable-5" in result + # Curated order is preserved at the front. + assert result[:len(curated)] == list(curated) + + +def test_anthropic_merge_dedupes_overlap_and_appends_live_only(): + """Models in both lists appear once; live-only models are appended.""" + live = [ + "claude-opus-4-8", # overlaps curated + "claude-sonnet-4-6", # overlaps curated + "claude-future-9-99", # live-only, not curated + ] + with patch.object(M, "_fetch_anthropic_models", return_value=live): + result = M.provider_model_ids("anthropic") + + # No duplicates introduced by the merge. + assert result.count("claude-opus-4-8") == 1 + # Live-only entry is preserved (discovery still works for unknown models). + assert "claude-future-9-99" in result + # Curated entries lead, live-only trails. + assert result.index("claude-fable-5") < result.index("claude-future-9-99") + + +def test_anthropic_falls_back_to_curated_when_live_unavailable(): + """No creds / live failure -> curated list verbatim (alias still present).""" + with patch.object(M, "_fetch_anthropic_models", return_value=None): + result = M.provider_model_ids("anthropic") + + assert result == list(M._PROVIDER_MODELS["anthropic"]) + assert "claude-fable-5" in result