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
- 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
Open PRs0
Fixes on record
-
#15063 fix(copilot): Discord /model flow — picker lifecycle, catalog, slugs, test-mock pollution
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.pyShow 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.00means 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 LabAt 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.