{"repo":"NousResearch/hermes-agent","public_id":"12086","display_label":"Copilot model picker bugs, slugs, and validation fixes","summary":"These issues and PRs center on regressions and fixes in the /model command flow, particularly for the GitHub Copilot provider, covering model ID normalization, slug mismatches, catalog fallbacks, and CLI validation edge cases. Several adjacent issues touch non-Copilot providers (Cloudflare, minimax-cn, user-defined providers) that share the same validation and picker infrastructure.","subsystem":"hermes_cli","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15063},"resolution":{"state":"partially_resolved","by":"pr-15063"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T10:33:29+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2247619047619048},"fix_edges":[{"issue_number":15030,"pr_number":15063,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":12086,"title":"[Bug]: Hermes v0.10.0 Discord /model command fails for Copilot model selection after upgrade","url":null,"state":"closed"},{"kind":"issue","number":12919,"title":"/model regressions: minimax-cn validation blocks valid switches; user-defined providers from config.providers can fail direct --provider switching","url":null,"state":"closed"},{"kind":"issue","number":15030,"title":"[Bug]: Discord /model flow has multiple Copilot regressions","url":null,"state":"closed"},{"kind":"pr","number":12100,"title":"fix(models): Copilot /model validation when /models is unreachable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fe34741f32d9d96d9fb78a305e4b4cfd393f59b2","number":0},{"kind":"pr","value":"#12086","number":12086},{"kind":"pr","value":"#12086","number":12086}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13552,"title":"fix(models): fallback to static catalog when live /models returns incomplete list","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"658ac1d866569fbf7b35cbf57a1352ef21f0f373","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15031,"title":"fix(model): repair Discord Copilot /model flow","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b4c030025","number":0},{"kind":"commit","value":"7cbd7819f","number":0},{"kind":"commit","value":"02dc919c2","number":0},{"kind":"pr","value":"#15063","number":15063}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15063,"title":"fix(copilot): Discord /model flow — picker lifecycle, catalog, slugs, test-mock pollution","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","gateway/platforms/discord.py","hermes_cli/model_switch.py","hermes_cli/models.py","scripts/release.py","tests/agent/test_auxiliary_client.py","tests/agent/test_auxiliary_named_custom_providers.py","tests/gateway/conftest.py","tests/gateway/test_discord_model_picker.py","tests/hermes_cli/test_copilot_in_model_list.py","tests/hermes_cli/test_model_validation.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -74,6 +74,12 @@\n     \"minimax_cn\": \"minimax-cn\",\n     \"claude\": \"anthropic\",\n     \"claude-code\": \"anthropic\",\n+    \"github\": \"copilot\",\n+    \"github-copilot\": \"copilot\",\n+    \"github-model\": \"copilot\",\n+    \"github-models\": \"copilot\",\n+    \"github-copilot-acp\": \"copilot-acp\",\n+    \"copilot-acp-agent\": \"copilot-acp\",\n }\n \n ","@@ -89,10 +95,11 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:\n     if normalized == \"main\":\n         # Resolve to the user's actual main provider so named custom providers\n         # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n-        main_prov = _read_main_provider()\n+        main_prov = (_read_main_provider() or \"\").strip().lower()\n         if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n-            return main_prov\n-        return \"custom\"\n+            normalized = main_prov\n+        else:\n+            return \"custom\"\n     return _PROVIDER_ALIASES.get(normalized, normalized)\n \n "]},{"file":"gateway/platforms/discord.py","hunks":["@@ -3871,6 +3871,15 @@ async def _on_model_selected(self, interaction: discord.Interaction):\n \n             self.resolved = True\n             model_id = interaction.data[\"values\"][0]\n+            self.clear_items()\n+            await interaction.response.edit_message(\n+                embed=discord.Embed(\n+                    title=\"⚙ Switching Model\",\n+                    description=f\"Switching to `{model_id}`...\",\n+                    color=discord.Color.blue(),\n+                ),\n+                view=None,\n+            )\n \n             try:\n                 result_text = await self.on_model_selected(","@@ -3881,14 +3890,13 @@ async def _on_model_selected(self, interaction: discord.Interaction):\n             except Exception as exc:\n                 result_text = f\"Error switching model: {exc}\"\n \n-            self.clear_items()\n-            await interaction.response.edit_message(\n+            await interaction.edit_original_response(\n                 embed=discord.Embed(\n                     title=\"⚙ Model Switched\",\n                     description=result_text,\n                     color=discord.Color.green(),\n                 ),\n-                view=self,\n+                view=None,\n             )\n \n         async def _on_back(self, interaction: discord.Interaction):"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -936,7 +936,7 @@ def list_authenticated_providers(\n     from hermes_cli.auth import PROVIDER_REGISTRY\n     from hermes_cli.models import (\n         OPENROUTER_MODELS, _PROVIDER_MODELS,\n-        _MODELS_DEV_PREFERRED, _merge_with_models_dev,\n+        _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,\n     )\n \n     results: List[dict] = []","@@ -1095,11 +1095,14 @@ def list_authenticated_providers(\n         if not has_creds:\n             continue\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+        if hermes_slug in {\"copilot\", \"copilot-acp\"}:\n+            model_ids = provider_model_ids(hermes_slug)\n+        else:\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":["@@ -155,10 +155,13 @@ def _codex_curated_models() -> list[str]:\n         \"gpt-4.1\",\n         \"gpt-4o\",\n         \"gpt-4o-mini\",\n-        \"claude-opus-4.6\",\n         \"claude-sonnet-4.6\",\n+        \"claude-sonnet-4\",\n         \"claude-sonnet-4.5\",\n         \"claude-haiku-4.5\",\n+        \"gemini-3.1-pro-preview\",\n+        \"gemini-3-pro-preview\",\n+        \"gemini-3-flash-preview\",\n         \"gemini-2.5-pro\",\n         \"grok-code-fast-1\",\n     ],","@@ -1932,6 +1935,7 @@ def _fetch_github_models(api_key: [redacted:secret-assignment] = None, timeout: float = 5.0) ->\n     \"openai/o4-mini\": \"gpt-5-mini\",\n     \"anthropic/claude-opus-4.6\": \"claude-opus-4.6\",\n     \"anthropic/claude-sonnet-4.6\": \"claude-sonnet-4.6\",\n+    \"anthropic/claude-sonnet-4\": \"claude-sonnet-4\",\n     \"anthropic/claude-sonnet-4.5\": \"claude-sonnet-4.5\",\n     \"anthropic/claude-haiku-4.5\": \"claude-haiku-4.5\",\n     # Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use","@@ -1941,10 +1945,12 @@ def _fetch_github_models(api_key: [redacted:secret-assignment] = None, timeout: float = 5.0) ->\n     # \"model_not_supported\".  See issue #6879.\n     \"claude-opus-4-6\": \"claude-opus-4.6\",\n     \"claude-sonnet-4-6\": \"claude-sonnet-4.6\",\n+    \"claude-sonnet-4-0\": \"claude-sonnet-4\",\n     \"claude-sonnet-4-5\": \"claude-sonnet-4.5\",\n     \"claude-haiku-4-5\": \"claude-haiku-4.5\",\n     \"anthropic/claude-opus-4-6\": \"claude-opus-4.6\",\n     \"anthropic/claude-sonnet-4-6\": \"claude-sonnet-4.6\",\n+    \"anthropic/claude-sonnet-4-0\": \"claude-sonnet-4\",\n     \"anthropic/claude-sonnet-4-5\": \"claude-sonnet-4.5\",\n     \"anthropic/claude-haiku-4-5\": \"claude-haiku-4.5\",\n }"]},{"file":"scripts/release.py","hunks":["@@ -444,6 +444,8 @@\n     \"[redacted:email]\": \"mrunmayee17\",\n     \"[redacted:email]\": \"camaragon\",\n     \"[redacted:email]\": \"shamork\",\n+    # April 2026 Discord Copilot /model salvage (#15030)\n+    \"[redacted:email]\": \"Nicecsh\",\n     # no-github-match — keep as display names\n     \"[redacted:email]\": \"Sisyphus\",\n     \"[redacted:email]\": \"Marco Rutsch\","]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -19,6 +19,7 @@\n     _read_codex_access_token,\n     _get_provider_chain,\n     _is_payment_error,\n+    _normalize_aux_provider,\n     _try_payment_fallback,\n     _resolve_auto,\n )","@@ -54,6 +55,17 @@ def codex_auth_dir(tmp_path, monkeypatch):\n     return codex_dir\n \n \n+class TestNormalizeAuxProvider:\n+    def test_maps_github_copilot_aliases(self):\n+        assert _normalize_aux_provider(\"github\") == \"copilot\"\n+        assert _normalize_aux_provider(\"github-copilot\") == \"copilot\"\n+        assert _normalize_aux_provider(\"github-models\") == \"copilot\"\n+\n+    def test_maps_github_copilot_acp_aliases(self):\n+        assert _normalize_aux_provider(\"github-copilot-acp\") == \"copilot-acp\"\n+        assert _normalize_aux_provider(\"copilot-acp-agent\") == \"copilot-acp\"\n+\n+\n class TestReadCodexAccessToken:\n     def test_valid_auth_store(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \"hermes\""]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -100,6 +100,26 @@ def test_main_with_custom_colon_prefix(self, tmp_path):\n         assert client is not None\n         assert \"beans.local\" in str(client.base_url)\n \n+    def test_main_resolves_github_copilot_alias(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"model\": {\"default\": \"gpt-5.4\", \"provider\": \"github-copilot\"},\n+        })\n+        with (\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\n+                \"api_key\": \"ghu_test_token\",\n+                \"base_url\": \"https://api.githubcopilot.com\",\n+            }),\n+            patch(\"agent.auxiliary_client.OpenAI\") as mock_openai,\n+        ):\n+            mock_openai.return_value = MagicMock()\n+            from agent.auxiliary_client import resolve_provider_client\n+\n+            client, model = resolve_provider_client(\"main\", \"gpt-5.4\")\n+\n+        assert client is not None\n+        assert model == \"gpt-5.4\"\n+        assert mock_openai.called\n+\n \n class TestResolveProviderClientNamedCustom:\n     \"\"\"resolve_provider_client should resolve named custom providers directly.\"\"\""]},{"file":"tests/gateway/conftest.py","hunks":["@@ -88,11 +88,63 @@ def _ensure_discord_mock() -> None:\n     discord_mod.Thread = type(\"Thread\", (), {})\n     discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n     discord_mod.Interaction = object\n-    discord_mod.Embed = MagicMock\n+    discord_mod.Message = type(\"Message\", (), {})\n+\n+    # Embed: accept the kwargs production code / tests use\n+    # (title, description, color). MagicMock auto-attributes work too,\n+    # but some tests construct and inspect .title/.description directly.\n+    class _FakeEmbed:\n+        def __init__(self, *, title=None, description=None, color=None, **_):\n+            self.title = title\n+            self.description = description\n+            self.color = color\n+    discord_mod.Embed = _FakeEmbed\n+\n+    # ui.View / ui.Select / ui.Button: real classes (not MagicMock) so\n+    # tests that subclass ModelPickerView / iterate .children / clear\n+    # items work.\n+    class _FakeView:\n+        def __init__(self, timeout=None):\n+            self.timeout = timeout\n+            self.children = []\n+        def add_item(self, item):\n+            self.children.append(item)\n+        def clear_items(self):\n+            self.children.clear()\n+\n+    class _FakeSelect:\n+        def __init__(self, *, placeholder=None, options=None, custom_id=None, **_):\n+            self.placeholder = placeholder\n+            self.options = options or []\n+            self.custom_id = custom_id\n+            self.callback = None\n+            self.disabled = False\n+\n+    class _FakeButton:\n+        def __init__(self, *, label=None, style=None, custom_id=None, emoji=None,\n+                     url=None, disabled=False, row=None, sku_id=None, **_):\n+            self.label = label\n+            self.style = style\n+            self.custom_id = custom_id\n+            self.emoji = emoji\n+            self.url = url\n+            self.disabled = disabled\n+            self.row = row\n+            self.sku_id = sku_id\n+            self.callback = None\n+\n+    class _FakeSelectOption:\n+        def __init__(self, *, label=None, value=None, description=None, **_):\n+            self.label = label\n+            self.value = value\n+            self.description = description\n+    discord_mod.SelectOption = _FakeSelectOption\n+\n     discord_mod.ui = SimpleNamespace(\n-        View=object,\n+        View=_FakeView,\n+        Select=_FakeSelect,\n+        Button=_FakeButton,\n         button=lambda *a, **k: (lambda fn: fn),\n-        Button=object,\n     )\n     discord_mod.ButtonStyle = SimpleNamespace(\n         success=1, primary=2, secondary=2, danger=3,","@@ -100,7 +152,7 @@ def _ensure_discord_mock() -> None:\n     )\n     discord_mod.Color = SimpleNamespace(\n         orange=lambda: 1, green=lambda: 2, blue=lambda: 3,\n-        red=lambda: 4, purple=lambda: 5,\n+        red=lambda: 4, purple=lambda: 5, greyple=lambda: 6,\n     )\n \n     # app_commands — needed by _register_slash_commands auto-registration"]},{"file":"tests/gateway/test_discord_model_picker.py","hunks":["@@ -0,0 +1,82 @@\n+\"\"\"Regression tests for the Discord /model picker.\n+\n+Uses the shared discord mock from tests/gateway/conftest.py (installed\n+at collection time via _ensure_discord_mock()). Previously this file\n+installed its own mock at module-import time and clobbered sys.modules,\n+breaking other gateway tests under pytest-xdist.\n+\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock\n+\n+import pytest\n+\n+from gateway.platforms.discord import ModelPickerView\n+\n+\n[redacted:email]\n+async def test_model_picker_clears_controls_before_running_switch_callback():\n+    events: list[object] = []\n+\n+    async def on_model_selected(chat_id: str, model_id: str, provider_slug: str) -> str:\n+        events.append((\"switch\", chat_id, model_id, provider_slug))\n+        return \"Model switched\"\n+\n+    async def edit_message(**kwargs):\n+        events.append(\n+            (\n+                \"initial-edit\",\n+                kwargs[\"embed\"].title,\n+                kwargs[\"embed\"].description,\n+                kwargs[\"view\"],\n+            )\n+        )\n+\n+    async def edit_original_response(**kwargs):\n+        events.append((\n+            \"final-edit\",\n+            kwargs[\"embed\"].title,\n+            kwargs[\"embed\"].description,\n+            kwargs[\"view\"],\n+        ))\n+\n+    view = ModelPickerView(\n+        providers=[\n+            {\n+                \"slug\": \"copilot\",\n+                \"name\": \"GitHub Copilot\",\n+                \"models\": [\"gpt-5.4\"],\n+                \"total_models\": 1,\n+                \"is_current\": True,\n+            }\n+        ],\n+        current_model=\"gpt-5-mini\",\n+        current_provider=\"copilot\",\n+        session_key=\"session-1\",\n+        on_model_selected=on_model_selected,\n+        allowed_user_ids=set(),\n+    )\n+    view._selected_provider = \"copilot\"\n+\n+    interaction = SimpleNamespace(\n+        user=SimpleNamespace(id=123),\n+        channel_id=456,\n+        data={\"values\": [\"gpt-5.4\"]},\n+        response=SimpleNamespace(\n+            defer=AsyncMock(),\n+            send_message=AsyncMock(),\n+            edit_message=AsyncMock(side_effect=edit_message),\n+        ),\n+        edit_original_response=AsyncMock(side_effect=edit_original_response),\n+    )\n+\n+    await view._on_model_selected(interaction)\n+\n+    assert events == [\n+        (\"initial-edit\", \"⚙ Switching Model\", \"Switching to `gpt-5.4`...\", None),\n+        (\"switch\", \"456\", \"gpt-5.4\", \"copilot\"),\n+        (\"final-edit\", \"⚙ Model Switched\", \"Model switched\", None),\n+    ]\n+    interaction.response.edit_message.assert_awaited_once()\n+    interaction.response.defer.assert_not_called()\n+    interaction.edit_original_response.assert_awaited_once()"]},{"file":"tests/hermes_cli/test_copilot_in_model_list.py","hunks":["@@ -0,0 +1,41 @@\n+\"\"\"Tests for GitHub Copilot entries shown in the /model picker.\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+from hermes_cli.model_switch import list_authenticated_providers\n+\n+\n[redacted:email](os.environ, {\"GH_TOKEN\": \"test-key\"}, clear=False)\n+def test_copilot_picker_keeps_curated_copilot_models_when_live_catalog_unavailable():\n+    with patch(\"agent.models_dev.fetch_models_dev\", return_value={}), \\\n+         patch(\"hermes_cli.models._resolve_copilot_catalog_api_key\", return_value=\"gh-token\"), \\\n+         patch(\"hermes_cli.models._fetch_github_models\", return_value=None):\n+        providers = list_authenticated_providers(current_provider=\"openrouter\", max_models=50)\n+\n+    copilot = next((p for p in providers if p[\"slug\"] == \"copilot\"), None)\n+\n+    assert copilot is not None\n+    assert \"gpt-5.4\" in copilot[\"models\"]\n+    assert \"claude-sonnet-4.6\" in copilot[\"models\"]\n+    assert \"claude-sonnet-4\" in copilot[\"models\"]\n+    assert \"claude-sonnet-4.5\" in copilot[\"models\"]\n+    assert \"claude-haiku-4.5\" in copilot[\"models\"]\n+    assert \"gemini-3.1-pro-preview\" in copilot[\"models\"]\n+    assert \"claude-opus-4.6\" not in copilot[\"models\"]\n+\n+\n[redacted:email](os.environ, {\"GH_TOKEN\": \"test-key\"}, clear=False)\n+def test_copilot_picker_uses_live_catalog_when_available():\n+    live_models = [\"gpt-5.4\", \"claude-sonnet-4.6\", \"gemini-3.1-pro-preview\"]\n+\n+    with patch(\"agent.models_dev.fetch_models_dev\", return_value={}), \\\n+         patch(\"hermes_cli.models._resolve_copilot_catalog_api_key\", return_value=\"gh-token\"), \\\n+         patch(\"hermes_cli.models._fetch_github_models\", return_value=live_models):\n+        providers = list_authenticated_providers(current_provider=\"openrouter\", max_models=50)\n+\n+    copilot = next((p for p in providers if p[\"slug\"] == \"copilot\"), None)\n+\n+    assert copilot is not None\n+    assert copilot[\"models\"] == live_models\n+    assert copilot[\"total_models\"] == len(live_models)"]},{"file":"tests/hermes_cli/test_model_validation.py","hunks":["@@ -220,13 +220,30 @@ def test_copilot_acp_reuses_copilot_catalog(self):\n              patch(\"hermes_cli.models._fetch_github_models\", return_value=[\"gpt-5.4\", \"claude-sonnet-4.6\"]):\n             assert provider_model_ids(\"copilot-acp\") == [\"gpt-5.4\", \"claude-sonnet-4.6\"]\n \n+    def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self):\n+        with patch(\"hermes_cli.models._resolve_copilot_catalog_api_key\", return_value=\"gh-token\"), \\\n+             patch(\"hermes_cli.models._fetch_github_models\", return_value=None):\n+            ids = provider_model_ids(\"copilot\")\n+\n+        assert \"gpt-5.4\" in ids\n+        assert \"claude-sonnet-4.6\" in ids\n+        assert \"claude-sonnet-4\" in ids\n+        assert \"claude-sonnet-4.5\" in ids\n+        assert \"claude-haiku-4.5\" in ids\n+        assert \"gemini-3.1-pro-preview\" in ids\n+        assert \"claude-opus-4.6\" not in ids\n+\n     def test_copilot_acp_falls_back_to_copilot_defaults(self):\n-        with patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", side_effect=Exception(\"no token\")), \\\n+        with patch(\"hermes_cli.models._resolve_copilot_catalog_api_key\", return_value=\"gh-token\"), \\\n              patch(\"hermes_cli.models._fetch_github_models\", return_value=None):\n             ids = provider_model_ids(\"copilot-acp\")\n \n         assert \"gpt-5.4\" in ids\n+        assert \"claude-sonnet-4.6\" in ids\n+        assert \"claude-sonnet-4\" in ids\n+        assert \"gemini-3.1-pro-preview\" in ids\n         assert \"copilot-acp\" not in ids\n+        assert \"claude-opus-4.6\" not in ids\n \n \n # -- fetch_api_models --------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}