mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
2e2de124af
commit
fe34741f32
6 changed files with 265 additions and 11 deletions
|
|
@ -3871,6 +3871,15 @@ if DISCORD_AVAILABLE:
|
||||||
|
|
||||||
self.resolved = True
|
self.resolved = True
|
||||||
model_id = interaction.data["values"][0]
|
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:
|
try:
|
||||||
result_text = await self.on_model_selected(
|
result_text = await self.on_model_selected(
|
||||||
|
|
@ -3881,14 +3890,13 @@ if DISCORD_AVAILABLE:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result_text = f"Error switching model: {exc}"
|
result_text = f"Error switching model: {exc}"
|
||||||
|
|
||||||
self.clear_items()
|
await interaction.edit_original_response(
|
||||||
await interaction.response.edit_message(
|
|
||||||
embed=discord.Embed(
|
embed=discord.Embed(
|
||||||
title="⚙ Model Switched",
|
title="⚙ Model Switched",
|
||||||
description=result_text,
|
description=result_text,
|
||||||
color=discord.Color.green(),
|
color=discord.Color.green(),
|
||||||
),
|
),
|
||||||
view=self,
|
view=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _on_back(self, interaction: discord.Interaction):
|
async def _on_back(self, interaction: discord.Interaction):
|
||||||
|
|
|
||||||
|
|
@ -936,7 +936,7 @@ def list_authenticated_providers(
|
||||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
from hermes_cli.models import (
|
from hermes_cli.models import (
|
||||||
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
||||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev,
|
_MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
results: List[dict] = []
|
results: List[dict] = []
|
||||||
|
|
@ -1095,11 +1095,14 @@ def list_authenticated_providers(
|
||||||
if not has_creds:
|
if not has_creds:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
if hermes_slug in {"copilot", "copilot-acp"}:
|
||||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
model_ids = provider_model_ids(hermes_slug)
|
||||||
# Merge with models.dev for preferred providers (same rationale as above).
|
else:
|
||||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
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)
|
total = len(model_ids)
|
||||||
top = model_ids[:max_models]
|
top = model_ids[:max_models]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,10 +155,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
"gpt-4.1",
|
"gpt-4.1",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-4o-mini",
|
"gpt-4o-mini",
|
||||||
"claude-opus-4.6",
|
|
||||||
"claude-sonnet-4.6",
|
"claude-sonnet-4.6",
|
||||||
|
"claude-sonnet-4",
|
||||||
"claude-sonnet-4.5",
|
"claude-sonnet-4.5",
|
||||||
"claude-haiku-4.5",
|
"claude-haiku-4.5",
|
||||||
|
"gemini-3.1-pro-preview",
|
||||||
|
"gemini-3-pro-preview",
|
||||||
|
"gemini-3-flash-preview",
|
||||||
"gemini-2.5-pro",
|
"gemini-2.5-pro",
|
||||||
"grok-code-fast-1",
|
"grok-code-fast-1",
|
||||||
],
|
],
|
||||||
|
|
@ -1932,6 +1935,7 @@ _COPILOT_MODEL_ALIASES = {
|
||||||
"openai/o4-mini": "gpt-5-mini",
|
"openai/o4-mini": "gpt-5-mini",
|
||||||
"anthropic/claude-opus-4.6": "claude-opus-4.6",
|
"anthropic/claude-opus-4.6": "claude-opus-4.6",
|
||||||
"anthropic/claude-sonnet-4.6": "claude-sonnet-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-sonnet-4.5": "claude-sonnet-4.5",
|
||||||
"anthropic/claude-haiku-4.5": "claude-haiku-4.5",
|
"anthropic/claude-haiku-4.5": "claude-haiku-4.5",
|
||||||
# Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use
|
# Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use
|
||||||
|
|
@ -1941,10 +1945,12 @@ _COPILOT_MODEL_ALIASES = {
|
||||||
# "model_not_supported". See issue #6879.
|
# "model_not_supported". See issue #6879.
|
||||||
"claude-opus-4-6": "claude-opus-4.6",
|
"claude-opus-4-6": "claude-opus-4.6",
|
||||||
"claude-sonnet-4-6": "claude-sonnet-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-sonnet-4-5": "claude-sonnet-4.5",
|
||||||
"claude-haiku-4-5": "claude-haiku-4.5",
|
"claude-haiku-4-5": "claude-haiku-4.5",
|
||||||
"anthropic/claude-opus-4-6": "claude-opus-4.6",
|
"anthropic/claude-opus-4-6": "claude-opus-4.6",
|
||||||
"anthropic/claude-sonnet-4-6": "claude-sonnet-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-sonnet-4-5": "claude-sonnet-4.5",
|
||||||
"anthropic/claude-haiku-4-5": "claude-haiku-4.5",
|
"anthropic/claude-haiku-4-5": "claude-haiku-4.5",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
179
tests/gateway/test_discord_model_picker.py
Normal file
179
tests/gateway/test_discord_model_picker.py
Normal 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()
|
||||||
41
tests/hermes_cli/test_copilot_in_model_list.py
Normal file
41
tests/hermes_cli/test_copilot_in_model_list.py
Normal 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)
|
||||||
|
|
@ -220,13 +220,30 @@ class TestProviderModelIds:
|
||||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
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"]
|
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):
|
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):
|
patch("hermes_cli.models._fetch_github_models", return_value=None):
|
||||||
ids = provider_model_ids("copilot-acp")
|
ids = provider_model_ids("copilot-acp")
|
||||||
|
|
||||||
assert "gpt-5.4" in ids
|
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 "copilot-acp" not in ids
|
||||||
|
assert "claude-opus-4.6" not in ids
|
||||||
|
|
||||||
|
|
||||||
# -- fetch_api_models --------------------------------------------------------
|
# -- fetch_api_models --------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue