Merge pull request #13622 from NousResearch/bb/tui-model-switch-sticks

fix(model-switch): /model --provider X sticks instead of silently falling back
This commit is contained in:
brooklyn! 2026-04-21 16:34:19 -05:00 committed by GitHub
commit e6e993552a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 158 additions and 0 deletions

View file

@ -0,0 +1,93 @@
"""Regression test for TUI v2 blitz bug: explicit /model --provider switch
silently fell back to the old primary provider on the next turn because the
fallback chain seeded from config at agent __init__ kept entries for the
provider the user just moved away from.
Reported: "switched from openrouter provider to anthropic api key via hermes
model and the tui keeps trying openrouter".
"""
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
def _make_agent(chain):
agent = AIAgent.__new__(AIAgent)
agent.provider = "openrouter"
agent.model = "x-ai/grok-4"
agent.base_url = "https://openrouter.ai/api/v1"
agent.api_key = "or-key"
agent.api_mode = "chat_completions"
agent.client = MagicMock()
agent._client_kwargs = {"api_key": "or-key", "base_url": "https://openrouter.ai/api/v1"}
agent.context_compressor = None
agent._anthropic_api_key = ""
agent._anthropic_base_url = None
agent._anthropic_client = None
agent._is_anthropic_oauth = False
agent._cached_system_prompt = "cached"
agent._primary_runtime = {}
agent._fallback_activated = False
agent._fallback_index = 0
agent._fallback_chain = list(chain)
agent._fallback_model = chain[0] if chain else None
return agent
def _switch_to_anthropic(agent):
with (
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-xyz"),
patch("agent.anthropic_adapter._is_oauth_token", return_value=False),
patch("hermes_cli.timeouts.get_provider_request_timeout", return_value=None),
):
agent.switch_model(
new_model="claude-sonnet-4-5",
new_provider="anthropic",
api_key="sk-ant-xyz",
base_url="https://api.anthropic.com",
api_mode="anthropic_messages",
)
def test_switch_drops_old_primary_from_fallback_chain():
agent = _make_agent([
{"provider": "openrouter", "model": "x-ai/grok-4"},
{"provider": "nous", "model": "hermes-4"},
])
_switch_to_anthropic(agent)
providers = [entry["provider"] for entry in agent._fallback_chain]
assert "openrouter" not in providers, "old primary must be pruned"
assert "anthropic" not in providers, "new primary is redundant in the chain"
assert providers == ["nous"]
assert agent._fallback_model == {"provider": "nous", "model": "hermes-4"}
def test_switch_with_empty_chain_stays_empty():
agent = _make_agent([])
_switch_to_anthropic(agent)
assert agent._fallback_chain == []
assert agent._fallback_model is None
def test_switch_within_same_provider_preserves_chain():
chain = [{"provider": "openrouter", "model": "x-ai/grok-4"}]
agent = _make_agent(chain)
with patch("hermes_cli.timeouts.get_provider_request_timeout", return_value=None):
agent.switch_model(
new_model="openai/gpt-5",
new_provider="openrouter",
api_key="or-key",
base_url="https://openrouter.ai/api/v1",
)
assert agent._fallback_chain == chain

View file

@ -1,4 +1,5 @@
import json
import os
import sys
import threading
import time
@ -230,6 +231,48 @@ def test_config_set_model_global_persists(monkeypatch):
assert saved["model"]["base_url"] == "https://api.anthropic.com"
def test_config_set_model_syncs_inference_provider_env(monkeypatch):
"""After an explicit provider switch, HERMES_INFERENCE_PROVIDER must
reflect the user's choice so ambient re-resolution (credential pool
refresh, aux clients) picks up the new provider instead of the original
one persisted in config or shell env.
Regression: a TUI user switched openrouter anthropic and the TUI kept
trying openrouter because the env-var-backed resolvers still saw the old
provider.
"""
class _Agent:
provider = "openrouter"
model = "old/model"
base_url = ""
api_key = "sk-or"
def switch_model(self, **_kwargs):
return None
result = types.SimpleNamespace(
success=True,
new_model="claude-sonnet-4.6",
target_provider="anthropic",
api_key="sk-ant",
base_url="https://api.anthropic.com",
api_mode="anthropic_messages",
warning_message="",
)
server._sessions["sid"] = _session(agent=_Agent())
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result)
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
server.handle_request(
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "claude-sonnet-4.6 --provider anthropic"}}
)
assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic"
def test_config_set_personality_rejects_unknown_name(monkeypatch):
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
resp = server.handle_request(