fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
"""Regression tests for the `/model` slash command in the interactive CLI."""
|
2026-03-07 19:56:48 -08:00
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
from unittest.mock import patch, MagicMock
|
2026-03-07 19:56:48 -08:00
|
|
|
|
|
|
|
|
from cli import HermesCLI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestModelCommand:
|
|
|
|
|
def _make_cli(self):
|
|
|
|
|
cli_obj = HermesCLI.__new__(HermesCLI)
|
|
|
|
|
cli_obj.model = "anthropic/claude-opus-4.6"
|
|
|
|
|
cli_obj.agent = object()
|
|
|
|
|
cli_obj.provider = "openrouter"
|
|
|
|
|
cli_obj.requested_provider = "openrouter"
|
|
|
|
|
cli_obj.base_url = "https://openrouter.ai/api/v1"
|
2026-03-08 05:22:15 -07:00
|
|
|
cli_obj.api_key = "test-key"
|
2026-03-07 19:56:48 -08:00
|
|
|
cli_obj._explicit_api_key = None
|
|
|
|
|
cli_obj._explicit_base_url = None
|
|
|
|
|
return cli_obj
|
|
|
|
|
|
2026-03-08 05:22:15 -07:00
|
|
|
def test_valid_model_from_api_saved_to_config(self, capsys):
|
2026-03-07 19:56:48 -08:00
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
with patch("hermes_cli.models.fetch_api_models",
|
2026-03-08 05:22:15 -07:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
assert "saved to config" in output
|
|
|
|
|
assert cli_obj.model == "anthropic/claude-sonnet-4.5"
|
|
|
|
|
save_mock.assert_called_once_with("model.default", "anthropic/claude-sonnet-4.5")
|
|
|
|
|
|
2026-03-12 16:02:35 -07:00
|
|
|
def test_unlisted_model_accepted_with_warning(self, capsys):
|
2026-03-08 05:22:15 -07:00
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
with patch("hermes_cli.models.fetch_api_models",
|
2026-03-08 05:22:15 -07:00
|
|
|
return_value=["anthropic/claude-opus-4.6"]), \
|
2026-03-07 19:56:48 -08:00
|
|
|
patch("cli.save_config_value") as save_mock:
|
2026-03-08 05:22:15 -07:00
|
|
|
cli_obj.process_command("/model anthropic/fake-model")
|
2026-03-07 19:56:48 -08:00
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
2026-03-12 16:02:35 -07:00
|
|
|
assert "not found" in output or "Model changed" in output
|
|
|
|
|
assert cli_obj.model == "anthropic/fake-model" # accepted
|
2026-03-07 19:56:48 -08:00
|
|
|
|
2026-03-12 16:02:35 -07:00
|
|
|
def test_api_unreachable_accepts_and_persists(self, capsys):
|
2026-03-07 19:56:48 -08:00
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
|
2026-03-07 19:56:48 -08:00
|
|
|
patch("cli.save_config_value") as save_mock:
|
|
|
|
|
cli_obj.process_command("/model anthropic/claude-sonnet-next")
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
2026-03-12 16:02:35 -07:00
|
|
|
assert "saved to config" in output
|
2026-03-07 19:56:48 -08:00
|
|
|
assert cli_obj.model == "anthropic/claude-sonnet-next"
|
2026-03-12 16:02:35 -07:00
|
|
|
save_mock.assert_called_once()
|
fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
|
2026-03-12 16:02:35 -07:00
|
|
|
def test_no_slash_model_accepted_with_warning(self, capsys):
|
fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
with patch("hermes_cli.models.fetch_api_models",
|
2026-03-08 05:31:41 -07:00
|
|
|
return_value=["openai/gpt-5.4"]) as fetch_mock, \
|
2026-03-08 05:22:15 -07:00
|
|
|
patch("cli.save_config_value") as save_mock:
|
2026-03-08 05:31:41 -07:00
|
|
|
cli_obj.process_command("/model gpt-5.4")
|
fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
2026-03-16 04:34:45 -07:00
|
|
|
# Auto-detection remaps bare model names to proper OpenRouter slugs
|
|
|
|
|
assert cli_obj.model == "openai/gpt-5.4"
|
fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
|
|
|
|
|
def test_validation_crash_falls_back_to_save(self, capsys):
|
|
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
|
2026-03-08 05:45:55 -07:00
|
|
|
with patch("hermes_cli.models.validate_requested_model",
|
2026-03-08 05:22:15 -07:00
|
|
|
side_effect=RuntimeError("boom")), \
|
fix: guard validate_requested_model + expand test coverage (PR #649 follow-up)
- Wrap validate_requested_model in try/except so /model doesn't crash
if validation itself fails (falls back to old accept+save behavior)
- Remove unnecessary sys.path.insert from both test files
- Expand test_model_validation.py: 4 → 23 tests covering normalize_provider,
provider_model_ids, empty/whitespace/spaces rejection, OpenRouter format
validation, custom endpoints, nous provider, provider aliases, unknown
providers, fuzzy suggestions
- Expand test_cli_model_command.py: 2 → 5 tests adding known-model save,
validation crash fallback, and /model with no argument
2026-03-08 04:47:31 -07:00
|
|
|
patch("cli.save_config_value", return_value=True) as save_mock:
|
|
|
|
|
cli_obj.process_command("/model anthropic/claude-sonnet-4.5")
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
assert "saved to config" in output
|
|
|
|
|
assert cli_obj.model == "anthropic/claude-sonnet-4.5"
|
|
|
|
|
save_mock.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_show_model_when_no_argument(self, capsys):
|
|
|
|
|
cli_obj = self._make_cli()
|
|
|
|
|
cli_obj.process_command("/model")
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
assert "anthropic/claude-opus-4.6" in output
|
2026-03-08 05:45:55 -07:00
|
|
|
assert "OpenRouter" in output
|
2026-03-11 23:06:06 -07:00
|
|
|
assert "Authenticated providers" in output or "Switch model" in output
|
|
|
|
|
assert "provider" in output and "model" in output
|
2026-03-08 05:45:55 -07:00
|
|
|
|
|
|
|
|
# -- 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"
|
feat(model): /model command overhaul — Phases 2, 3, 5
* feat(model): persist base_url on /model switch, auto-detect for bare /model custom
Phase 2+3 of the /model command overhaul:
Phase 2 — Persist base_url on model switch:
- CLI: save model.base_url when switching to a non-OpenRouter endpoint;
clear it when switching away from custom to prevent stale URLs
leaking into the new provider's resolution
- Gateway: same logic using direct YAML write
Phase 3 — Better feedback and edge cases:
- Bare '/model custom' now auto-detects the model from the endpoint
using _auto_detect_local_model() and saves all three config values
(model, provider, base_url) atomically
- Shows endpoint URL in success messages when switching to/from
custom providers (both CLI and gateway)
- Clear error messages when no custom endpoint is configured
- Updated test assertions for the additional save_config_value call
Fixes #2562 (Phase 2+3)
* feat(model): support custom:name:model triple syntax for named custom providers
Phase 5 of the /model command overhaul.
Extends parse_model_input() to handle the triple syntax:
/model custom:local-server:qwen → provider='custom:local-server', model='qwen'
/model custom:my-model → provider='custom', model='my-model' (unchanged)
The 'custom:local-server' provider string is already supported by
_get_named_custom_provider() in runtime_provider.py, which matches
it against the custom_providers list in config.yaml. This just wires
the parsing so users can do it from the /model slash command.
Added 4 tests covering single, triple, whitespace, and empty model cases.
2026-03-24 06:58:04 -07:00
|
|
|
# Model, provider, and base_url should be saved
|
|
|
|
|
assert save_mock.call_count == 3
|
|
|
|
|
save_calls = [c.args for c in save_mock.call_args_list]
|
|
|
|
|
assert ("model.default", "glm-5") in save_calls
|
|
|
|
|
assert ("model.provider", "zai") in save_calls
|
|
|
|
|
# base_url is also persisted on provider change (Phase 2 fix)
|
|
|
|
|
assert any(c[0] == "model.base_url" for c in save_calls)
|
2026-03-08 05:45:55 -07:00
|
|
|
|
|
|
|
|
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
|