{"repo":"NousResearch/hermes-agent","public_id":"6756","display_label":"Model list accuracy fixes for CLI and Telegram","summary":"These issues and pull requests address incorrect or incomplete model listings, including a missing models.dev fallback, merging additional provider entries, and ensuring the model picker shows the full catalog rather than a curated subset.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14221,"merged_at":"2026-04-23T00:33:43+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-23T00:33:43+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":6756,"pr_number":14221,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-23T00:33:43+00:00"}],"members":[{"kind":"issue","number":6756,"title":"[Bug]:Model list is incorrect in Telegram","url":null,"state":"closed"},{"kind":"pr","number":6789,"title":"fix(cli): implement missing models.dev fallback for provider model li…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6756","number":6756},{"kind":"pr","value":"#14221","number":14221}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14221,"title":"feat(/model): merge models.dev entries for lesser-loved providers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/model_switch.py","hermes_cli/models.py","tests/hermes_cli/test_models_dev_preferred_merge.py","tests/hermes_cli/test_opencode_go_in_model_list.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -810,7 +810,10 @@ def list_authenticated_providers(\n         get_provider_info as _mdev_pinfo,\n     )\n     from hermes_cli.auth import PROVIDER_REGISTRY\n-    from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS\n+    from hermes_cli.models import (\n+        OPENROUTER_MODELS, _PROVIDER_MODELS,\n+        _MODELS_DEV_PREFERRED, _merge_with_models_dev,\n+    )\n \n     results: List[dict] = []\n     seen_slugs: set = set()  # lowercase-normalized to catch case variants (#9545)","@@ -856,8 +859,13 @@ def list_authenticated_providers(\n         if not has_creds:\n             continue\n \n-        # Use curated list, falling back to models.dev if no curated list\n+        # Use curated list, falling back to models.dev if no curated list.\n+        # For preferred providers, merge models.dev entries into the curated\n+        # catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)\n+        # show up in the picker without requiring a Hermes release.\n         model_ids = curated.get(hermes_id, [])\n+        if hermes_id in _MODELS_DEV_PREFERRED:\n+            model_ids = _merge_with_models_dev(hermes_id, model_ids)\n         total = len(model_ids)\n         top = model_ids[:max_models]\n ","@@ -961,6 +969,9 @@ def list_authenticated_providers(\n \n         # Use curated list — look up by Hermes slug, fall back to overlay key\n         model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])\n+        # Merge with models.dev for preferred providers (same rationale as above).\n+        if hermes_slug in _MODELS_DEV_PREFERRED:\n+            model_ids = _merge_with_models_dev(hermes_slug, model_ids)\n         total = len(model_ids)\n         top = model_ids[:max_models]\n "]},{"file":"hermes_cli/models.py","hunks":["@@ -1587,11 +1587,84 @@ def _resolve_copilot_catalog_api_key() -> str:\n         return \"\"\n \n \n+# Providers where models.dev is treated as authoritative: curated static\n+# lists are kept only as an offline fallback and to capture custom additions\n+# the registry doesn't publish yet. Adding a provider here causes its\n+# curated list to be merged with fresh models.dev entries (fresh first, any\n+# curated-only names appended) for both the CLI and the gateway /model picker.\n+#\n+# DELIBERATELY EXCLUDED:\n+#   - \"openrouter\": curated list is already a hand-picked agentic subset of\n+#     OpenRouter's 400+ catalog. Blindly merging would dump everything.\n+#   - \"nous\": curated list and Portal /models endpoint are the source of\n+#     truth for the subscription tier.\n+# Also excluded: providers that already have dedicated live-endpoint\n+# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom,\n+# stepfun, openai-codex) — those paths handle freshness themselves.\n+_MODELS_DEV_PREFERRED: frozenset[str] = frozenset({\n+    \"opencode-go\",\n+    \"opencode-zen\",\n+    \"deepseek\",\n+    \"kilocode\",\n+    \"fireworks\",\n+    \"mistral\",\n+    \"togetherai\",\n+    \"cohere\",\n+    \"perplexity\",\n+    \"groq\",\n+    \"nvidia\",\n+    \"huggingface\",\n+    \"zai\",\n+    \"gemini\",\n+    \"google\",\n+})\n+\n+\n+def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:\n+    \"\"\"Merge curated list with fresh models.dev entries for a preferred provider.\n+\n+    Returns models.dev entries first (in models.dev order), then any\n+    curated-only entries appended. Preserves case for curated fallbacks\n+    (e.g. ``MiniMax-M2.7``) while trusting models.dev for newer variants.\n+\n+    If models.dev is unreachable or returns nothing, the curated list is\n+    returned unchanged — this is the offline/CI fallback path.\n+    \"\"\"\n+    try:\n+        from agent.models_dev import list_agentic_models\n+        mdev = list_agentic_models(provider)\n+    except Exception:\n+        mdev = []\n+\n+    if not mdev:\n+        return list(curated)\n+\n+    # Case-insensitive dedup while preserving order and curated casing.\n+    seen_lower: set[str] = set()\n+    merged: list[str] = []\n+    for mid in mdev:\n+        key = str(mid).lower()\n+        if key in seen_lower:\n+            continue\n+        seen_lower.add(key)\n+        merged.append(mid)\n+    for mid in curated:\n+        key = str(mid).lower()\n+        if key in seen_lower:\n+            continue\n+        seen_lower.add(key)\n+        merged.append(mid)\n+    return merged\n+\n+\n def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:\n     \"\"\"Return the best known model catalog for a provider.\n \n     Tries live API endpoints for providers that support them (Codex, Nous),\n-    falling back to static lists.\n+    falling back to static lists. For providers in ``_MODELS_DEV_PREFERRED``\n+    (opencode-go/zen, xiaomi, deepseek, smaller inference providers, etc.),\n+    models.dev entries are merged on top of curated so new models released\n+    on the platform appear in ``/model`` without a Hermes release.\n     \"\"\"\n     normalized = normalize_provider(provider)\n     if normalized == \"openrouter\":","@@ -1657,7 +1730,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)\n             live = fetch_api_models(api_key, base_url)\n             if live:\n                 return live\n-    return list(_PROVIDER_MODELS.get(normalized, []))\n+    curated_static = list(_PROVIDER_MODELS.get(normalized, []))\n+    if normalized in _MODELS_DEV_PREFERRED:\n+        return _merge_with_models_dev(normalized, curated_static)\n+    return curated_static\n \n \n def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:"]},{"file":"tests/hermes_cli/test_models_dev_preferred_merge.py","hunks":["@@ -0,0 +1,124 @@\n+\"\"\"Tests for the models.dev-preferred merge behavior in provider_model_ids\n+and list_authenticated_providers.\n+\n+These guard the contract:\n+\n+  * For providers in ``_MODELS_DEV_PREFERRED`` (opencode-go, opencode-zen,\n+    xiaomi, deepseek, smaller inference providers), both the CLI model\n+    picker path (``provider_model_ids``) and the gateway ``/model`` picker\n+    path (``list_authenticated_providers``) merge fresh models.dev entries\n+    on top of the curated static list.\n+  * OpenRouter and Nous Portal are NEVER merged — they keep their curated\n+    (OpenRouter) or live-Portal (Nous) semantics.\n+  * If models.dev is unreachable (offline / CI), the curated list is the\n+    fallback — no crash, no empty list.\n+\n+Merging is what lets new models (e.g. ``mimo-v2.5-pro`` on opencode-go)\n+appear in ``/model`` without a Hermes release.\n+\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from hermes_cli.models import (\n+    _MODELS_DEV_PREFERRED,\n+    _merge_with_models_dev,\n+    provider_model_ids,\n+)\n+\n+\n+class TestMergeHelper:\n+    def test_merge_empty_mdev_returns_curated(self):\n+        \"\"\"When models.dev returns nothing, curated list is preserved verbatim.\"\"\"\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=[]):\n+            out = _merge_with_models_dev(\"opencode-go\", [\"mimo-v2-pro\", \"kimi-k2.6\"])\n+        assert out == [\"mimo-v2-pro\", \"kimi-k2.6\"]\n+\n+    def test_merge_mdev_raises_returns_curated(self):\n+        \"\"\"Offline / broken models.dev must not break the catalog path.\"\"\"\n+        def boom(_provider):\n+            raise RuntimeError(\"network down\")\n+\n+        with patch(\"agent.models_dev.list_agentic_models\", side_effect=boom):\n+            out = _merge_with_models_dev(\"opencode-go\", [\"mimo-v2-pro\"])\n+        assert out == [\"mimo-v2-pro\"]\n+\n+    def test_merge_mdev_first_then_curated_extras(self):\n+        \"\"\"models.dev entries come first; curated-only entries are appended.\"\"\"\n+        mdev = [\"mimo-v2.5-pro\", \"mimo-v2-pro\", \"kimi-k2.6\"]\n+        curated = [\"kimi-k2.6\", \"kimi-k2.5\", \"mimo-v2-pro\"]  # kimi-k2.5 is curated-only\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=mdev):\n+            out = _merge_with_models_dev(\"opencode-go\", curated)\n+        # models.dev entries first (in order), then curated-only entries\n+        assert out == [\"mimo-v2.5-pro\", \"mimo-v2-pro\", \"kimi-k2.6\", \"kimi-k2.5\"]\n+\n+    def test_merge_case_insensitive_dedup(self):\n+        \"\"\"Dedup is case-insensitive but preserves the first occurrence's casing.\"\"\"\n+        mdev = [\"MiniMax-M2.7\"]\n+        curated = [\"minimax-m2.7\", \"minimax-m2.5\"]\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=mdev):\n+            out = _merge_with_models_dev(\"minimax\", curated)\n+        # models.dev casing wins since it came first\n+        assert out == [\"MiniMax-M2.7\", \"minimax-m2.5\"]\n+\n+\n+class TestProviderModelIdsPreferred:\n+    def test_opencode_go_is_preferred(self):\n+        assert \"opencode-go\" in _MODELS_DEV_PREFERRED\n+\n+    def test_opencode_go_includes_fresh_models_dev_entries(self):\n+        \"\"\"provider_model_ids('opencode-go') adds models.dev entries on top.\"\"\"\n+        mdev = [\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-pro\", \"kimi-k2.6\"]\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=mdev):\n+            out = provider_model_ids(\"opencode-go\")\n+        # Fresh models must surface (this is exactly the reported bug fix:\n+        # mimo-v2.5-pro should be pickable on opencode-go).\n+        assert \"mimo-v2.5-pro\" in out\n+        assert \"mimo-v2.5\" in out\n+        # Curated entries are still present.\n+        assert \"mimo-v2-pro\" in out\n+        assert \"kimi-k2.6\" in out\n+\n+    def test_opencode_go_offline_falls_back_to_curated(self):\n+        \"\"\"Offline models.dev → curated-only list, no crash.\"\"\"\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=[]):\n+            out = provider_model_ids(\"opencode-go\")\n+        # Curated floor (see hermes_cli/models.py _PROVIDER_MODELS[\"opencode-go\"])\n+        assert \"mimo-v2-pro\" in out\n+        assert \"kimi-k2.6\" in out\n+\n+    def test_opencode_zen_includes_fresh_models(self):\n+        \"\"\"opencode-zen follows the same pattern as opencode-go.\"\"\"\n+        assert \"opencode-zen\" in _MODELS_DEV_PREFERRED\n+        mdev = [\"claude-opus-4-7\", \"kimi-k2.6\", \"glm-5.1\"]\n+        with patch(\"agent.models_dev.list_agentic_models\", return_value=mdev):\n+            out = provider_model_ids(\"opencode-zen\")\n+        assert \"claude-opus-4-7\" in out\n+        assert \"kimi-k2.6\" in out\n+\n+\n+class TestOpenRouterAndNousUnchanged:\n+    \"\"\"Per Teknium: openrouter and nous are NEVER merged with models.dev.\"\"\"\n+\n+    def test_openrouter_not_in_preferred_set(self):\n+        assert \"openrouter\" not in _MODELS_DEV_PREFERRED\n+\n+    def test_nous_not_in_preferred_set(self):\n+        assert \"nous\" not in _MODELS_DEV_PREFERRED\n+\n+    def test_openrouter_does_not_call_merge(self):\n+        \"\"\"openrouter takes its own live path — merge helper must NOT run.\"\"\"\n+        with patch(\n+            \"hermes_cli.models._merge_with_models_dev\",\n+            side_effect=AssertionError(\"merge should not be called for openrouter\"),\n+        ):\n+            # Even if model_ids() fails for some other reason, we just care\n+            # that the merge path isn't invoked.\n+            try:\n+                provider_model_ids(\"openrouter\")\n+            except AssertionError:\n+                raise\n+            except Exception:\n+                pass  # model_ids() may fail in the hermetic test env — that's fine."]},{"file":"tests/hermes_cli/test_opencode_go_in_model_list.py","hunks":["@@ -6,16 +6,41 @@\n from hermes_cli.model_switch import list_authenticated_providers\n \n \n+# Minimum set of models that must be present for opencode-go no matter\n+# whether the picker sourced its list from curated-only or curated+models.dev.\n+# The curated list in hermes_cli/models.py defines the floor; models.dev only\n+# ever adds names on top of it via _merge_with_models_dev.\n+_OPENCODE_GO_REQUIRED = {\n+    \"kimi-k2.6\",\n+    \"kimi-k2.5\",\n+    \"glm-5.1\",\n+    \"glm-5\",\n+    \"mimo-v2-pro\",\n+    \"mimo-v2-omni\",\n+    \"minimax-m2.7\",\n+    \"minimax-m2.5\",\n+}\n+\n+\n @patch.dict(os.environ, {\"OPENCODE_GO_API_KEY\": \"[redacted:secret-assignment]\"}, clear=False)\n def test_opencode_go_appears_when_api_key_set():\n     \"\"\"opencode-go should appear in list_authenticated_providers when OPENCODE_GO_API_KEY is set.\"\"\"\n-    providers = list_authenticated_providers(current_provider=\"openrouter\")\n-    \n+    providers = list_authenticated_providers(current_provider=\"openrouter\", max_models=50)\n+\n     # Find opencode-go in results\n     opencode_go = next((p for p in providers if p[\"slug\"] == \"opencode-go\"), None)\n-    \n+\n     assert opencode_go is not None, \"opencode-go should appear when OPENCODE_GO_API_KEY is set\"\n-    assert opencode_go[\"models\"] == [\"kimi-k2.6\", \"kimi-k2.5\", \"glm-5.1\", \"glm-5\", \"mimo-v2-pro\", \"mimo-v2-omni\", \"minimax-m2.7\", \"minimax-m2.5\"]\n+    # Behavior check: the curated floor must be present. The list may also\n+    # include extra models.dev entries (e.g. mimo-v2.5-pro) when the registry\n+    # is reachable — that's the whole point of the models.dev-preferred merge\n+    # introduced for opencode-go, so don't pin to an exact list here.\n+    present = set(opencode_go[\"models\"])\n+    missing = _OPENCODE_GO_REQUIRED - present\n+    assert not missing, (\n+        f\"opencode-go picker should include the curated floor; missing: {sorted(missing)}. \"\n+        f\"Got: {opencode_go['models']}\"\n+    )\n     # opencode-go can appear as \"built-in\" (from PROVIDER_TO_MODELS_DEV when\n     # models.dev is reachable) or \"hermes\" (from HERMES_OVERLAYS fallback when\n     # the API is unavailable, e.g. in CI).","@@ -26,10 +51,10 @@ def test_opencode_go_not_appears_when_no_creds():\n     \"\"\"opencode-go should NOT appear when no credentials are set.\"\"\"\n     # Ensure OPENCODE_GO_API_KEY is not set\n     env_without_key = {k: v for k, v in os.environ.items() if k != \"OPENCODE_GO_API_KEY\"}\n-    \n+\n     with patch.dict(os.environ, env_without_key, clear=True):\n         providers = list_authenticated_providers(current_provider=\"openrouter\")\n-        \n+\n         # opencode-go should not be in results\n         opencode_go = next((p for p in providers if p[\"slug\"] == \"opencode-go\"), None)\n         assert opencode_go is None, \"opencode-go should not appear without credentials\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}