feat: provider switching via /model + enhanced model display

Add provider:model syntax to /model command for runtime provider switching:
  /model zai:glm-5           → switch to Z.AI provider with glm-5
  /model nous:hermes-3       → switch to Nous Portal with hermes-3
  /model openrouter:anthropic/claude-sonnet-4.5  → explicit OpenRouter

When switching providers, credentials are resolved via resolve_runtime_provider
and validated before committing. Both model and provider are saved to config.
Provider aliases work (glm: → zai, kimi: → kimi-coding, etc.).

Enhanced /model (no args) display now shows:
  - Current model and provider
  - Curated model list for the current provider with ← marker
  - Usage examples including provider:model syntax

39 tests covering parse_model_input, curated_models_for_provider,
provider switching (success + credential failure), and display output.
This commit is contained in:
teknium1 2026-03-08 05:45:55 -07:00
parent 4a09ae2985
commit 66d3e6a0c2
4 changed files with 213 additions and 92 deletions

View file

@ -3,8 +3,10 @@
from unittest.mock import patch
from hermes_cli.models import (
curated_models_for_provider,
fetch_api_models,
normalize_provider,
parse_model_input,
provider_model_ids,
validate_requested_model,
)
@ -12,7 +14,6 @@ from hermes_cli.models import (
# -- helpers -----------------------------------------------------------------
# Simulated API model list for mocking fetch_api_models
FAKE_API_MODELS = [
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.5",
@ -28,6 +29,61 @@ def _validate(model, provider="openrouter", api_models=FAKE_API_MODELS, **kw):
return validate_requested_model(model, provider, **kw)
# -- parse_model_input -------------------------------------------------------
class TestParseModelInput:
def test_plain_model_keeps_current_provider(self):
provider, model = parse_model_input("anthropic/claude-sonnet-4.5", "openrouter")
assert provider == "openrouter"
assert model == "anthropic/claude-sonnet-4.5"
def test_provider_colon_model_switches_provider(self):
provider, model = parse_model_input("openrouter:anthropic/claude-sonnet-4.5", "nous")
assert provider == "openrouter"
assert model == "anthropic/claude-sonnet-4.5"
def test_provider_alias_resolved(self):
provider, model = parse_model_input("glm:glm-5", "openrouter")
assert provider == "zai"
assert model == "glm-5"
def test_no_slash_no_colon_keeps_provider(self):
provider, model = parse_model_input("gpt-5.4", "openrouter")
assert provider == "openrouter"
assert model == "gpt-5.4"
def test_nous_provider_switch(self):
provider, model = parse_model_input("nous:hermes-3", "openrouter")
assert provider == "nous"
assert model == "hermes-3"
def test_empty_model_after_colon_keeps_current(self):
provider, model = parse_model_input("openrouter:", "nous")
assert provider == "nous"
assert model == "openrouter:"
def test_colon_at_start_keeps_current(self):
provider, model = parse_model_input(":something", "openrouter")
assert provider == "openrouter"
assert model == ":something"
# -- curated_models_for_provider ---------------------------------------------
class TestCuratedModelsForProvider:
def test_openrouter_returns_curated_list(self):
models = curated_models_for_provider("openrouter")
assert len(models) > 0
assert any("claude" in m[0] for m in models)
def test_zai_returns_glm_models(self):
models = curated_models_for_provider("zai")
assert any("glm" in m[0] for m in models)
def test_unknown_provider_returns_empty(self):
assert curated_models_for_provider("totally-unknown") == []
# -- normalize_provider ------------------------------------------------------
class TestNormalizeProvider:
@ -37,21 +93,11 @@ class TestNormalizeProvider:
def test_known_aliases(self):
assert normalize_provider("glm") == "zai"
assert normalize_provider("z-ai") == "zai"
assert normalize_provider("z.ai") == "zai"
assert normalize_provider("zhipu") == "zai"
assert normalize_provider("kimi") == "kimi-coding"
assert normalize_provider("moonshot") == "kimi-coding"
assert normalize_provider("minimax-china") == "minimax-cn"
def test_canonical_ids_pass_through(self):
assert normalize_provider("openrouter") == "openrouter"
assert normalize_provider("nous") == "nous"
assert normalize_provider("openai-codex") == "openai-codex"
def test_case_insensitive(self):
assert normalize_provider("OpenRouter") == "openrouter"
assert normalize_provider("GLM") == "zai"
# -- provider_model_ids ------------------------------------------------------
@ -66,11 +112,7 @@ class TestProviderModelIds:
assert provider_model_ids("some-unknown-provider") == []
def test_zai_returns_glm_models(self):
ids = provider_model_ids("zai")
assert "glm-5" in ids
def test_alias_resolves_correctly(self):
assert provider_model_ids("glm") == provider_model_ids("zai")
assert "glm-5" in provider_model_ids("zai")
# -- fetch_api_models --------------------------------------------------------
@ -78,14 +120,13 @@ class TestProviderModelIds:
class TestFetchApiModels:
def test_returns_none_when_no_base_url(self):
assert fetch_api_models("key", None) is None
assert fetch_api_models("key", "") is None
def test_returns_none_on_network_error(self):
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=Exception("timeout")):
assert fetch_api_models("key", "https://example.com/v1") is None
# -- validate_requested_model — format checks (no API needed) ----------------
# -- validate — format checks -----------------------------------------------
class TestValidateFormatChecks:
def test_empty_model_rejected(self):
@ -96,15 +137,12 @@ class TestValidateFormatChecks:
def test_whitespace_only_rejected(self):
result = _validate(" ")
assert result["accepted"] is False
assert "empty" in result["message"]
def test_model_with_spaces_rejected(self):
result = _validate("anthropic/ claude-opus")
assert result["accepted"] is False
assert "spaces" in result["message"].lower()
def test_no_slash_model_still_probes_api(self):
"""Models without '/' should still be checked via API (not all providers need it)."""
result = _validate("gpt-5.4", api_models=["gpt-5.4", "gpt-5.4-pro"])
assert result["accepted"] is True
assert result["persist"] is True
@ -112,80 +150,60 @@ class TestValidateFormatChecks:
def test_no_slash_model_rejected_if_not_in_api(self):
result = _validate("gpt-5.4", api_models=["openai/gpt-5.4"])
assert result["accepted"] is False
assert "not a valid model" in result["message"]
# -- validate_requested_model — API probe found model ------------------------
# -- validate — API found ----------------------------------------------------
class TestValidateApiFound:
def test_model_found_in_api_is_accepted_and_persisted(self):
def test_model_found_in_api(self):
result = _validate("anthropic/claude-opus-4.6")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
assert result["message"] is None
def test_model_found_in_api_for_custom_endpoint(self):
def test_model_found_for_custom_endpoint(self):
result = _validate(
"my-model",
provider="openrouter",
api_models=["my-model", "other-model"],
base_url="http://localhost:11434/v1",
"my-model", provider="openrouter",
api_models=["my-model"], base_url="http://localhost:11434/v1",
)
assert result["accepted"] is True
assert result["persist"] is True
# -- validate_requested_model — API probe model not found --------------------
# -- validate — API not found ------------------------------------------------
class TestValidateApiNotFound:
def test_model_not_in_api_is_rejected(self):
def test_model_not_in_api_rejected(self):
result = _validate("anthropic/claude-nonexistent")
assert result["accepted"] is False
assert result["persist"] is False
assert "not a valid model" in result["message"]
def test_rejection_includes_suggestions(self):
result = _validate("anthropic/claude-opus-4.5") # close to claude-opus-4.6
result = _validate("anthropic/claude-opus-4.5")
assert result["accepted"] is False
assert "Did you mean" in result["message"]
def test_completely_wrong_model_rejected(self):
result = _validate("totally/fake-model-xyz")
assert result["accepted"] is False
assert "not a valid model" in result["message"]
# -- validate_requested_model — API unreachable (fallback) -------------------
# -- validate — API unreachable (fallback) -----------------------------------
class TestValidateApiFallback:
def test_known_catalog_model_accepted_when_api_down(self):
"""If API is unreachable, fall back to hardcoded catalog."""
result = _validate("anthropic/claude-opus-4.6", api_models=None)
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
def test_unknown_model_is_session_only_when_api_down(self):
def test_unknown_model_session_only_when_api_down(self):
result = _validate("anthropic/claude-next-gen", api_models=None)
assert result["accepted"] is True
assert result["persist"] is False
assert "Could not validate" in result["message"]
assert "session only" in result["message"].lower()
def test_zai_known_model_accepted_when_api_down(self):
result = _validate("glm-5", provider="zai", api_models=None)
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is True
def test_zai_unknown_model_session_only_when_api_down(self):
result = _validate("glm-99", provider="zai", api_models=None)
assert result["accepted"] is True
assert result["persist"] is False
def test_unknown_provider_session_only_when_api_down(self):
result = _validate("some-model", provider="totally-unknown", api_models=None)
assert result["accepted"] is True
assert result["persist"] is False
assert result["message"] is not None

View file

@ -1,6 +1,6 @@
"""Regression tests for the `/model` slash command in the interactive CLI."""
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from cli import HermesCLI
@ -21,8 +21,7 @@ class TestModelCommand:
def test_valid_model_from_api_saved_to_config(self, capsys):
cli_obj = self._make_cli()
with patch("hermes_cli.auth.resolve_provider", return_value="openrouter"), \
patch("hermes_cli.models.fetch_api_models",
with patch("hermes_cli.models.fetch_api_models",
return_value=["anthropic/claude-sonnet-4.5", "openai/gpt-5.4"]), \
patch("cli.save_config_value", return_value=True) as save_mock:
cli_obj.process_command("/model anthropic/claude-sonnet-4.5")
@ -30,60 +29,51 @@ class TestModelCommand:
output = capsys.readouterr().out
assert "saved to config" in output
assert cli_obj.model == "anthropic/claude-sonnet-4.5"
assert cli_obj.agent is None
save_mock.assert_called_once_with("model.default", "anthropic/claude-sonnet-4.5")
def test_invalid_model_from_api_is_rejected(self, capsys):
cli_obj = self._make_cli()
with patch("hermes_cli.auth.resolve_provider", return_value="openrouter"), \
patch("hermes_cli.models.fetch_api_models",
with patch("hermes_cli.models.fetch_api_models",
return_value=["anthropic/claude-opus-4.6"]), \
patch("cli.save_config_value") as save_mock:
cli_obj.process_command("/model anthropic/fake-model")
output = capsys.readouterr().out
assert "not a valid model" in output
assert cli_obj.model == "anthropic/claude-opus-4.6" # unchanged
assert cli_obj.agent is not None # not reset
assert cli_obj.model == "anthropic/claude-opus-4.6"
save_mock.assert_not_called()
def test_model_when_api_unreachable_falls_back_session_only(self, capsys):
cli_obj = self._make_cli()
with patch("hermes_cli.auth.resolve_provider", return_value="openrouter"), \
patch("hermes_cli.models.fetch_api_models", return_value=None), \
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
patch("cli.save_config_value") as save_mock:
cli_obj.process_command("/model anthropic/claude-sonnet-next")
output = capsys.readouterr().out
assert "session only" in output
assert cli_obj.model == "anthropic/claude-sonnet-next"
assert cli_obj.agent is None
save_mock.assert_not_called()
def test_no_slash_model_probes_api_and_rejects(self, capsys):
"""Model without '/' is still probed via API — not rejected on format alone."""
cli_obj = self._make_cli()
with patch("hermes_cli.auth.resolve_provider", return_value="openrouter"), \
patch("hermes_cli.models.fetch_api_models",
with patch("hermes_cli.models.fetch_api_models",
return_value=["openai/gpt-5.4"]) as fetch_mock, \
patch("cli.save_config_value") as save_mock:
cli_obj.process_command("/model gpt-5.4")
output = capsys.readouterr().out
assert "not a valid model" in output
assert cli_obj.model == "anthropic/claude-opus-4.6" # unchanged
fetch_mock.assert_called_once() # API was probed
assert cli_obj.model == "anthropic/claude-opus-4.6"
fetch_mock.assert_called_once()
save_mock.assert_not_called()
def test_validation_crash_falls_back_to_save(self, capsys):
"""If validate_requested_model throws, /model should still work (old behavior)."""
cli_obj = self._make_cli()
with patch("hermes_cli.auth.resolve_provider", return_value="openrouter"), \
patch("hermes_cli.models.validate_requested_model",
with patch("hermes_cli.models.validate_requested_model",
side_effect=RuntimeError("boom")), \
patch("cli.save_config_value", return_value=True) as save_mock:
cli_obj.process_command("/model anthropic/claude-sonnet-4.5")
@ -99,4 +89,42 @@ class TestModelCommand:
output = capsys.readouterr().out
assert "anthropic/claude-opus-4.6" in output
assert "Usage" in output
assert "OpenRouter" in output
assert "Available models" in output
assert "provider:model-name" in output
# -- provider switching tests -------------------------------------------
def test_provider_colon_model_switches_provider(self, capsys):
cli_obj = self._make_cli()
with patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={
"provider": "zai",
"api_key": "zai-key",
"base_url": "https://api.z.ai/api/paas/v4",
}), \
patch("hermes_cli.models.fetch_api_models",
return_value=["glm-5", "glm-4.7"]), \
patch("cli.save_config_value", return_value=True) as save_mock:
cli_obj.process_command("/model zai:glm-5")
output = capsys.readouterr().out
assert "glm-5" in output
assert "provider:" in output.lower() or "Z.AI" in output
assert cli_obj.model == "glm-5"
assert cli_obj.provider == "zai"
assert cli_obj.base_url == "https://api.z.ai/api/paas/v4"
# Both model and provider should be saved
assert save_mock.call_count == 2
def test_provider_switch_fails_on_bad_credentials(self, capsys):
cli_obj = self._make_cli()
with patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=Exception("No API key found")):
cli_obj.process_command("/model nous:hermes-3")
output = capsys.readouterr().out
assert "Could not resolve credentials" in output
assert cli_obj.model == "anthropic/claude-opus-4.6" # unchanged
assert cli_obj.provider == "openrouter" # unchanged