fix(model): repair Discord Copilot /model flow

Keep Discord Copilot model switching responsive and current by refreshing picker data from the live catalog when possible, correcting the curated fallback list, and clearing stale controls before the switch completes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Nicecsh 2026-04-24 17:41:19 +08:00 committed by Teknium
parent 2e2de124af
commit fe34741f32
6 changed files with 265 additions and 11 deletions

View file

@ -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):

View file

@ -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]

View file

@ -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",
}

View file

@ -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()

View file

@ -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)

View file

@ -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 --------------------------------------------------------