diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1c564ca8a..3eaf6ac05 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3871,6 +3871,15 @@ if DISCORD_AVAILABLE: 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 @@ if DISCORD_AVAILABLE: 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): diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 41fbe36de..252d933b1 100644 --- a/hermes_cli/model_switch.py +++ b/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] diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8663db231..4d2302274 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -155,10 +155,13 @@ _PROVIDER_MODELS: dict[str, 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 @@ _COPILOT_MODEL_ALIASES = { "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 @@ _COPILOT_MODEL_ALIASES = { # "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", } diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py new file mode 100644 index 000000000..1fd8ac4de --- /dev/null +++ b/tests/gateway/test_discord_model_picker.py @@ -0,0 +1,179 @@ +"""Regression tests for the Discord /model picker.""" + +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import sys + +import pytest + + +def _ensure_discord_mock(): + existing = sys.modules.get("discord") + if isinstance(existing, ModuleType) and getattr(existing, "__file__", None): + return + + 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, options, custom_id): + self.placeholder = placeholder + self.options = options + self.custom_id = custom_id + self.callback = None + self.disabled = False + + class _FakeButton: + def __init__(self, *, label, style, 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, value, description=None): + self.label = label + self.value = value + self.description = description + + class _FakeEmbed: + def __init__(self, *, title, description, color): + self.title = title + self.description = description + self.color = color + + class _FakeColor: + @staticmethod + def green(): + return "green" + + @staticmethod + def blue(): + return "blue" + + @staticmethod + def red(): + return "red" + + @staticmethod + def greyple(): + return "greyple" + + class _FakeButtonStyle: + green = "green" + grey = "grey" + red = "red" + blurple = "blurple" + + discord_mod = sys.modules.get("discord") or MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.Message = type("Message", (), {}) + discord_mod.SelectOption = _FakeSelectOption + discord_mod.Embed = _FakeEmbed + discord_mod.Color = _FakeColor + discord_mod.ButtonStyle = _FakeButtonStyle + discord_mod.app_commands = getattr( + discord_mod, + "app_commands", + SimpleNamespace(describe=lambda **kwargs: (lambda fn: fn)), + ) + discord_mod.ui = SimpleNamespace( + View=_FakeView, + Select=_FakeSelect, + Button=_FakeButton, + button=lambda **kwargs: (lambda fn: fn), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules["discord"] = discord_mod + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import ModelPickerView + + +@pytest.mark.asyncio +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() diff --git a/tests/hermes_cli/test_copilot_in_model_list.py b/tests/hermes_cli/test_copilot_in_model_list.py new file mode 100644 index 000000000..e414687bc --- /dev/null +++ b/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 + + +@patch.dict(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"] + + +@patch.dict(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) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 6a1a230c4..913733028 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -220,13 +220,30 @@ class TestProviderModelIds: 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 --------------------------------------------------------