Copilot model picker bugs, slugs, and validation fixes

NousResearch/hermes-agent · cluster 12086 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #15063. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #15063. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #15063. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #15063 fix(copilot): Discord /model flow — picker lifecycle, catalog, slugs, test-mock pollution

    merged to main

    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

    Show the diff
    • agent/auxiliary_client.py
      @@ -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",
       }
       
       
      @@ -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
      @@ -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(
      @@ -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
      @@ -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] = []
      @@ -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
      @@ -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",
           ],
      @@ -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
      @@ -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
      @@ -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
      @@ -19,6 +19,7 @@
           _read_codex_access_token,
           _get_provider_chain,
           _is_payment_error,
      +    _normalize_aux_provider,
           _try_payment_fallback,
           _resolve_auto,
       )
      @@ -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
      @@ -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
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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 --------------------------------------------------------

3 PRs closed without merging

From the close comments: done elsewhere 2 · other 1

  • #12100 fix(models): Copilot /model validation when /models is unreachable · done elsewhere → fe34741, #12086
  • #13552 fix(models): fallback to static catalog when live /models returns incomplete list · done elsewhere → 658ac1d
  • #15031 fix(model): repair Discord Copilot /model flow · other → b4c0300, 7cbd781, 02dc919, #15063
Closed issues · 3
  • Closed#12086[Bug]: Hermes v0.10.0 Discord /model command fails for Copilot model selection after upgrade
  • Closed#12919/model regressions: minimax-cn validation blocks valid switches; user-defined providers from config.providers can fail direct --provider switching
  • Closed#15030[Bug]: Discord /model flow has multiple Copilot regressions
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#15063
Closed issues
3
Attempts
3 PRs closed without merging
Fix link checked
24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.