# NousResearch/hermes-agent · Copilot model picker bugs, slugs, and validation fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 7 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #15063. Verify it addresses your case.
- **Recommended:** read PR #15063 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-15063`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.22`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #12086: \[Bug\]: Hermes v0.10.0 Discord /model command fails for Copilot model selection after upgrade
- #12919: /model regressions: minimax-cn validation blocks valid switches; user-defined providers from config.providers can fail direct --provider switching
- #15030: \[Bug\]: Discord /model flow has multiple Copilot regressions

## Prior & competing PRs

- **#15031: fix(model): repair Discord Copilot /model flow**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `b4c030025`, `7cbd7819f`, `02dc919c2`, `#15063`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15063: fix(copilot): Discord /model flow — picker lifecycle, catalog, slugs, test-mock pollution**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -74,6 +74,12 @@
         "minimax_cn": "minimax-cn",
         "claude": "anthropic",
         "claude-code": "anthropic",
    +    "github": "copilot",
    +    "github-copilot": "copilot",
    +    "github-model": "copilot",
    +    "github-models": "copilot",
    +    "github-copilot-acp": "copilot-acp",
    +    "copilot-acp-agent": "copilot-acp",
     }
     
     
    ```

    ```diff
    @@ -89,10 +95,11 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:
         if normalized == "main":
             # Resolve to the user's actual main provider so named custom providers
             # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
    -        main_prov = _read_main_provider()
    +        main_prov = (_read_main_provider() or "").strip().lower()
             if main_prov and main_prov not in ("auto", "main", ""):
    -            return main_prov
    -        return "custom"
    +            normalized = main_prov
    +        else:
    +            return "custom"
         return _PROVIDER_ALIASES.get(normalized, normalized)
     
     
    ```
  - `gateway/platforms/discord.py`

    ```diff
    @@ -3871,6 +3871,15 @@ async def _on_model_selected(self, interaction: discord.Interaction):
     
                 self.resolved = True
                 model_id = interaction.data["values"][0]
    +            self.clear_items()
    +            await interaction.response.edit_message(
    +                embed=discord.Embed(
    +                    title="⚙ Switching Model",
    +                    description=f"Switching to `{model_id}`...",
    +                    color=discord.Color.blue(),
    +                ),
    +                view=None,
    +            )
     
                 try:
                     result_text = await self.on_model_selected(
    ```

    ```diff
    @@ -3881,14 +3890,13 @@ async def _on_model_selected(self, interaction: discord.Interaction):
                 except Exception as exc:
                     result_text = f"Error switching model: {exc}"
     
    -            self.clear_items()
    -            await interaction.response.edit_message(
    +            await interaction.edit_original_response(
                     embed=discord.Embed(
                         title="⚙ Model Switched",
                         description=result_text,
                         color=discord.Color.green(),
                     ),
    -                view=self,
    +                view=None,
                 )
     
             async def _on_back(self, interaction: discord.Interaction):
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -936,7 +936,7 @@ def list_authenticated_providers(
         from hermes_cli.auth import PROVIDER_REGISTRY
         from hermes_cli.models import (
             OPENROUTER_MODELS, _PROVIDER_MODELS,
    -        _MODELS_DEV_PREFERRED, _merge_with_models_dev,
    +        _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
         )
     
         results: List[dict] = []
    ```

    ```diff
    @@ -1095,11 +1095,14 @@ def list_authenticated_providers(
             if not has_creds:
                 continue
     
    -        # Use curated list — look up by Hermes slug, fall back to overlay key
    -        model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
    -        # Merge with models.dev for preferred providers (same rationale as above).
    -        if hermes_slug in _MODELS_DEV_PREFERRED:
    -            model_ids = _merge_with_models_dev(hermes_slug, model_ids)
    +        if hermes_slug in {"copilot", "copilot-acp"}:
    +            model_ids = provider_model_ids(hermes_slug)
    +        else:
    +            # Use curated list — look up by Hermes slug, fall back to overlay key
    +            model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
    +            # Merge with models.dev for preferred providers (same rationale as above).
    +            if hermes_slug in _MODELS_DEV_PREFERRED:
    +                model_ids = _merge_with_models_dev(hermes_slug, model_ids)
             total = len(model_ids)
             top = model_ids[:max_models]
     
    ```
  - `hermes_cli/models.py`

    ```diff
    @@ -155,10 +155,13 @@ def _codex_curated_models() -> list[str]:
             "gpt-4.1",
             "gpt-4o",
             "gpt-4o-mini",
    -        "claude-opus-4.6",
             "claude-sonnet-4.6",
    +        "claude-sonnet-4",
             "claude-sonnet-4.5",
             "claude-haiku-4.5",
    +        "gemini-3.1-pro-preview",
    +        "gemini-3-pro-preview",
    +        "gemini-3-flash-preview",
             "gemini-2.5-pro",
             "grok-code-fast-1",
         ],
    ```

    ```diff
    @@ -1932,6 +1935,7 @@ def _fetch_github_models(api_key: [redacted:secret-assignment] = None, timeout: float = 5.0) ->
         "openai/o4-mini": "gpt-5-mini",
         "anthropic/claude-opus-4.6": "claude-opus-4.6",
         "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6",
    +    "anthropic/claude-sonnet-4": "claude-sonnet-4",
         "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5",
         "anthropic/claude-haiku-4.5": "claude-haiku-4.5",
         # Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use
    ```

    ```diff
    @@ -1941,10 +1945,12 @@ def _fetch_github_models(api_key: [redacted:secret-assignment] = None, timeout: float = 5.0) ->
         # "model_not_supported".  See issue #6879.
         "claude-opus-4-6": "claude-opus-4.6",
         "claude-sonnet-4-6": "claude-sonnet-4.6",
    +    "claude-sonnet-4-0": "claude-sonnet-4",
         "claude-sonnet-4-5": "claude-sonnet-4.5",
         "claude-haiku-4-5": "claude-haiku-4.5",
         "anthropic/claude-opus-4-6": "claude-opus-4.6",
         "anthropic/claude-sonnet-4-6": "claude-sonnet-4.6",
    +    "anthropic/claude-sonnet-4-0": "claude-sonnet-4",
         "anthropic/claude-sonnet-4-5": "claude-sonnet-4.5",
         "anthropic/claude-haiku-4-5": "claude-haiku-4.5",
     }
    ```
  - `scripts/release.py`

    ```diff
    @@ -444,6 +444,8 @@
         "[redacted:email]": "mrunmayee17",
         "[redacted:email]": "camaragon",
         "[redacted:email]": "shamork",
    +    # April 2026 Discord Copilot /model salvage (#15030)
    +    "[redacted:email]": "Nicecsh",
         # no-github-match — keep as display names
         "[redacted:email]": "Sisyphus",
         "[redacted:email]": "Marco Rutsch",
    ```
  - `tests/agent/test_auxiliary_client.py`

    ```diff
    @@ -19,6 +19,7 @@
         _read_codex_access_token,
         _get_provider_chain,
         _is_payment_error,
    +    _normalize_aux_provider,
         _try_payment_fallback,
         _resolve_auto,
     )
    ```

    ```diff
    @@ -54,6 +55,17 @@ def codex_auth_dir(tmp_path, monkeypatch):
         return codex_dir
     
     
    +class TestNormalizeAuxProvider:
    +    def test_maps_github_copilot_aliases(self):
    +        assert _normalize_aux_provider("github") == "copilot"
    +        assert _normalize_aux_provider("github-copilot") == "copilot"
    +        assert _normalize_aux_provider("github-models") == "copilot"
    +
    +    def test_maps_github_copilot_acp_aliases(self):
    +        assert _normalize_aux_provider("github-copilot-acp") == "copilot-acp"
    +        assert _normalize_aux_provider("copilot-acp-agent") == "copilot-acp"
    +
    +
     class TestReadCodexAccessToken:
         def test_valid_auth_store(self, tmp_path, monkeypatch):
             hermes_home = tmp_path / "hermes"
    ```
  - `tests/agent/test_auxiliary_named_custom_providers.py`

    ```diff
    @@ -100,6 +100,26 @@ def test_main_with_custom_colon_prefix(self, tmp_path):
             assert client is not None
             assert "beans.local" in str(client.base_url)
     
    +    def test_main_resolves_github_copilot_alias(self, tmp_path):
    +        _write_config(tmp_path, {
    +            "model": {"default": "gpt-5.4", "provider": "github-copilot"},
    +        })
    +        with (
    +            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
    +                "api_key": "ghu_test_token",
    +                "base_url": "https://api.githubcopilot.com",
    +            }),
    +            patch("agent.auxiliary_client.OpenAI") as mock_openai,
    +        ):
    +            mock_openai.return_value = MagicMock()
    +            from agent.auxiliary_client import resolve_provider_client
    +
    +            client, model = resolve_provider_client("main", "gpt-5.4")
    +
    +        assert client is not None
    +        assert model == "gpt-5.4"
    +        assert mock_openai.called
    +
     
     class TestResolveProviderClientNamedCustom:
         """resolve_provider_client should resolve named custom providers directly."""
    ```
  - `tests/gateway/conftest.py`

    ```diff
    @@ -88,11 +88,63 @@ def _ensure_discord_mock() -> None:
         discord_mod.Thread = type("Thread", (), {})
         discord_mod.ForumChannel = type("ForumChannel", (), {})
         discord_mod.Interaction = object
    -    discord_mod.Embed = MagicMock
    +    discord_mod.Message = type("Message", (), {})
    +
    +    # Embed: accept the kwargs production code / tests use
    +    # (title, description, color). MagicMock auto-attributes work too,
    +    # but some tests construct and inspect .title/.description directly.
    +    class _FakeEmbed:
    +        def __init__(self, *, title=None, description=None, color=None, **_):
    +            self.title = title
    +            self.description = description
    +            self.color = color
    +    discord_mod.Embed = _FakeEmbed
    +
    +    # ui.View / ui.Select / ui.Button: real classes (not MagicMock) so
    +    # tests that subclass ModelPickerView / iterate .children / clear
    +    # items work.
    +    class _FakeView:
    +        def __init__(self, timeout=None):
    +            self.timeout = timeout
    +            self.children = []
    +        def add_item(self, item):
    +            self.children.append(item)
    +        def clear_items(self):
    +            self.children.clear()
    +
    +    class _FakeSelect:
    +        def __init__(self, *, placeholder=None, options=None, custom_id=None, **_):
    +            self.placeholder = placeholder
    +            self.options = options or []
    +            self.custom_id = custom_id
    +            self.callback = None
    +            self.disabled = False
    +
    +    class _FakeButton:
    +        def __init__(self, *, label=None, style=None, custom_id=None, emoji=None,
    +                     url=None, disabled=False, row=None, sku_id=None, **_):
    +            self.label = label
    +            self.style = style
    +            self.custom_id = custom_id
    +            self.emoji = emoji
    +            self.url = url
    +            self.disabled = disabled
    +            self.row = row
    +            self.sku_id = sku_id
    +            self.callback = None
    +
    +    class _FakeSelectOption:
    +        def __init__(self, *, label=None, value=None, description=None, **_):
    +            self.label = label
    +            self.value = value
    +            self.description = description
    +    discord_mod.SelectOption = _FakeSelectOption
    +
         discord_mod.ui = SimpleNamespace(
    -        View=object,
    +        View=_FakeView,
    +        Select=_FakeSelect,
    +        Button=_FakeButton,
             button=lambda *a, **k: (lambda fn: fn),
    -        Button=object,
         )
         discord_mod.ButtonStyle = SimpleNamespace(
             success=1, primary=2, secondary=2, danger=3,
    ```

    ```diff
    @@ -100,7 +152,7 @@ def _ensure_discord_mock() -> None:
         )
         discord_mod.Color = SimpleNamespace(
             orange=lambda: 1, green=lambda: 2, blue=lambda: 3,
    -        red=lambda: 4, purple=lambda: 5,
    +        red=lambda: 4, purple=lambda: 5, greyple=lambda: 6,
         )
     
         # app_commands — needed by _register_slash_commands auto-registration
    ```
  - `tests/gateway/test_discord_model_picker.py`

    ```diff
    @@ -0,0 +1,82 @@
    +"""Regression tests for the Discord /model picker.
    +
    +Uses the shared discord mock from tests/gateway/conftest.py (installed
    +at collection time via _ensure_discord_mock()). Previously this file
    +installed its own mock at module-import time and clobbered sys.modules,
    +breaking other gateway tests under pytest-xdist.
    +"""
    +
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock
    +
    +import pytest
    +
    +from gateway.platforms.discord import ModelPickerView
    +
    +
    [redacted:email]
    +async def test_model_picker_clears_controls_before_running_switch_callback():
    +    events: list[object] = []
    +
    +    async def on_model_selected(chat_id: str, model_id: str, provider_slug: str) -> str:
    +        events.append(("switch", chat_id, model_id, provider_slug))
    +        return "Model switched"
    +
    +    async def edit_message(**kwargs):
    +        events.append(
    +            (
    +                "initial-edit",
    +                kwargs["embed"].title,
    +                kwargs["embed"].description,
    +                kwargs["view"],
    +            )
    +        )
    +
    +    async def edit_original_response(**kwargs):
    +        events.append((
    +            "final-edit",
    +            kwargs["embed"].title,
    +            kwargs["embed"].description,
    +            kwargs["view"],
    +        ))
    +
    +    view = ModelPickerView(
    +        providers=[
    +            {
    +                "slug": "copilot",
    +                "name": "GitHub Copilot",
    +                "models": ["gpt-5.4"],
    +                "total_models": 1,
    +                "is_current": True,
    +            }
    +        ],
    +        current_model="gpt-5-mini",
    +        current_provider="copilot",
    +        session_key="session-1",
    +        on_model_selected=on_model_selected,
    +        allowed_user_ids=set(),
    +    )
    +    view._selected_provider = "copilot"
    +
    +    interaction = SimpleNamespace(
    +        user=SimpleNamespace(id=123),
    +        channel_id=456,
    +        data={"values": ["gpt-5.4"]},
    +        response=SimpleNamespace(
    +            defer=AsyncMock(),
    +            send_message=AsyncMock(),
    +            edit_message=AsyncMock(side_effect=edit_message),
    +        ),
    +        edit_original_response=AsyncMock(side_effect=edit_original_response),
    +    )
    +
    +    await view._on_model_selected(interaction)
    +
    +    assert events == [
    +        ("initial-edit", "⚙ Switching Model", "Switching to `gpt-5.4`...", None),
    +        ("switch", "456", "gpt-5.4", "copilot"),
    +        ("final-edit", "⚙ Model Switched", "Model switched", None),
    +    ]
    +    interaction.response.edit_message.assert_awaited_once()
    +    interaction.response.defer.assert_not_called()
    +    interaction.edit_original_response.assert_awaited_once()
    ```
  - `tests/hermes_cli/test_copilot_in_model_list.py`

    ```diff
    @@ -0,0 +1,41 @@
    +"""Tests for GitHub Copilot entries shown in the /model picker."""
    +
    +import os
    +from unittest.mock import patch
    +
    +from hermes_cli.model_switch import list_authenticated_providers
    +
    +
    [redacted:email](os.environ, {"GH_TOKEN": "test-key"}, clear=False)
    +def test_copilot_picker_keeps_curated_copilot_models_when_live_catalog_unavailable():
    +    with patch("agent.models_dev.fetch_models_dev", return_value={}), \
    +         patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
    +         patch("hermes_cli.models._fetch_github_models", return_value=None):
    +        providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
    +
    +    copilot = next((p for p in providers if p["slug"] == "copilot"), None)
    +
    +    assert copilot is not None
    +    assert "gpt-5.4" in copilot["models"]
    +    assert "claude-sonnet-4.6" in copilot["models"]
    +    assert "claude-sonnet-4" in copilot["models"]
    +    assert "claude-sonnet-4.5" in copilot["models"]
    +    assert "claude-haiku-4.5" in copilot["models"]
    +    assert "gemini-3.1-pro-preview" in copilot["models"]
    +    assert "claude-opus-4.6" not in copilot["models"]
    +
    +
    [redacted:email](os.environ, {"GH_TOKEN": "test-key"}, clear=False)
    +def test_copilot_picker_uses_live_catalog_when_available():
    +    live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
    +
    +    with patch("agent.models_dev.fetch_models_dev", return_value={}), \
    +         patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
    +         patch("hermes_cli.models._fetch_github_models", return_value=live_models):
    +        providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
    +
    +    copilot = next((p for p in providers if p["slug"] == "copilot"), None)
    +
    +    assert copilot is not None
    +    assert copilot["models"] == live_models
    +    assert copilot["total_models"] == len(live_models)
    ```
  - `tests/hermes_cli/test_model_validation.py`

    ```diff
    @@ -220,13 +220,30 @@ def test_copilot_acp_reuses_copilot_catalog(self):
                  patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
                 assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]
     
    +    def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self):
    +        with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
    +             patch("hermes_cli.models._fetch_github_models", return_value=None):
    +            ids = provider_model_ids("copilot")
    +
    +        assert "gpt-5.4" in ids
    +        assert "claude-sonnet-4.6" in ids
    +        assert "claude-sonnet-4" in ids
    +        assert "claude-sonnet-4.5" in ids
    +        assert "claude-haiku-4.5" in ids
    +        assert "gemini-3.1-pro-preview" in ids
    +        assert "claude-opus-4.6" not in ids
    +
         def test_copilot_acp_falls_back_to_copilot_defaults(self):
    -        with patch("hermes_cli.auth.resolve_api_key_provider_credentials", side_effect=Exception("no token")), \
    +        with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
                  patch("hermes_cli.models._fetch_github_models", return_value=None):
                 ids = provider_model_ids("copilot-acp")
     
             assert "gpt-5.4" in ids
    +        assert "claude-sonnet-4.6" in ids
    +        assert "claude-sonnet-4" in ids
    +        assert "gemini-3.1-pro-preview" in ids
             assert "copilot-acp" not in ids
    +        assert "claude-opus-4.6" not in ids
     
     
     # -- fetch_api_models --------------------------------------------------------
    ```
- **#12100: fix(models): Copilot /model validation when /models is unreachable**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `fe34741f32d9d96d9fb78a305e4b4cfd393f59b2`, `#12086`, `#12086`
- **#13552: fix(models): fallback to static catalog when live /models returns incomplete list**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `658ac1d866569fbf7b35cbf57a1352ef21f0f373`

## Fix status

- Merged to main in PR #15063 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-24`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
