"""Tests that provider selection via `hermes model` always persists correctly. Regression tests for the bug where _save_model_choice could save config.model as a plain string, causing subsequent provider writes (which check isinstance(model, dict)) to silently fail — leaving the provider unset and falling back to auto-detection. """ from unittest.mock import patch, MagicMock import pytest @pytest.fixture def config_home(tmp_path, monkeypatch): """Isolated HERMES_HOME with a minimal string-format config.""" home = tmp_path / "hermes" home.mkdir() config_yaml = home / "config.yaml" # Start with model as a plain string — the format that triggered the bug config_yaml.write_text("model: some-old-model\n") env_file = home / ".env" env_file.write_text("") monkeypatch.setenv("HERMES_HOME", str(home)) # Clear env vars that could interfere monkeypatch.delenv("HERMES_MODEL", raising=False) monkeypatch.delenv("LLM_MODEL", raising=False) monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("STEPFUN_API_KEY", raising=False) monkeypatch.delenv("STEPFUN_BASE_URL", raising=False) return home class TestSaveModelChoiceAlwaysDict: def test_string_model_becomes_dict(self, config_home): """When config.model is a plain string, _save_model_choice must convert it to a dict so provider can be set afterwards.""" from hermes_cli.auth import _save_model_choice _save_model_choice("kimi-k2.5") import yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), ( f"Expected model to be a dict after save, got {type(model)}: {model}" ) assert model["default"] == "kimi-k2.5" class TestProviderPersistsAfterModelSave: def test_update_config_for_provider_uses_atomic_yaml_write(self, config_home): """Provider switches should delegate config writes to atomic_yaml_write.""" from hermes_cli.auth import _update_config_for_provider config_path = config_home / "config.yaml" original_text = config_path.read_text(encoding="utf-8") def _boom(path, data, **kwargs): assert path == config_path assert data["model"]["provider"] == "nous" assert data["model"]["base_url"] == "https://inference.example.com/v1" assert data["model"]["default"] == "some-old-model" assert kwargs["sort_keys"] is False raise OSError("simulated atomic write failure") with patch("hermes_cli.auth.atomic_yaml_write", side_effect=_boom) as mock_write: with pytest.raises(OSError, match="simulated atomic write failure"): _update_config_for_provider( "nous", "https://inference.example.com/v1/", default_model="llama-3.3", ) assert mock_write.call_count == 1 assert config_path.read_text(encoding="utf-8") == original_text def test_api_key_provider_saved_when_model_was_string(self, config_home, monkeypatch): """_model_flow_api_key_provider must persist the provider even when config.model started as a plain string.""" from hermes_cli.auth import PROVIDER_REGISTRY pconfig = PROVIDER_REGISTRY.get("kimi-coding") if not pconfig: pytest.skip("kimi-coding not in PROVIDER_REGISTRY") # Simulate: user has a Kimi API key, model was a string monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key") from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config # Mock the model selection prompt to return "kimi-k2.5" # Also mock input() for the base URL prompt and builtins.input with patch("hermes_cli.auth._prompt_model_selection", return_value="kimi-k2.5"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("builtins.input", return_value=""): _model_flow_api_key_provider(load_config(), "kimi-coding", "old-model") import yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), f"model should be dict, got {type(model)}" assert model.get("provider") == "kimi-coding", ( f"provider should be 'kimi-coding', got {model.get('provider')}" ) assert model.get("default") == "kimi-k2.5" class TestBaseUrlValidation: """Reject non-URL values in the base URL prompt (e.g. shell commands). Uses MiniMax instead of Z.AI because Z.AI now uses a curses-based endpoint picker (_select_zai_endpoint) rather than the plain text input() prompt. Z.AI picker behavior is covered in TestZaiEndpointPicker below. """ def test_empty_base_url_keeps_default(self, config_home, monkeypatch): """Pressing Enter (empty) should not change the base URL.""" from hermes_cli.auth import PROVIDER_REGISTRY pconfig = PROVIDER_REGISTRY.get("minimax") if not pconfig: pytest.skip("minimax not in PROVIDER_REGISTRY") monkeypatch.setenv("MINIMAX_API_KEY", "test-key") monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config, get_env_value with patch("hermes_cli.auth._prompt_model_selection", return_value="MiniMax-M2"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("builtins.input", return_value=""): _model_flow_api_key_provider(load_config(), "minimax", "old-model") saved = get_env_value("MINIMAX_BASE_URL") or "" assert saved == "", "Empty input should not save a base URL" class TestZaiEndpointPicker: """Z.AI setup should present a curses picker for endpoint selection.""" def test_custom_proxy_rejects_invalid_url(self, config_home, monkeypatch, capsys): """Custom proxy must start with http:// or https://.""" from hermes_cli.main import _model_flow_api_key_provider from hermes_cli.config import load_config monkeypatch.setenv("GLM_API_KEY", "test-key") monkeypatch.delenv("GLM_BASE_URL", raising=False) from hermes_cli.auth import ZAI_ENDPOINTS custom_idx = len(ZAI_ENDPOINTS) with patch("hermes_cli.main._prompt_provider_choice", return_value=custom_idx), \ patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ patch("hermes_cli.auth.deactivate_provider"), \ patch("builtins.input", return_value="not-a-url"): _model_flow_api_key_provider(load_config(), "zai", "old-model") # The invalid URL should not have been saved as base_url model = load_config()["model"] assert model["base_url"] != "not-a-url" captured = capsys.readouterr() assert "Invalid URL" in captured.out def test_current_endpoint_is_default_choice(self, config_home, monkeypatch): """When a known endpoint is already active, it should be the default.""" from hermes_cli.auth import ZAI_ENDPOINTS from hermes_cli.model_setup_flows import _select_zai_endpoint coding_url = ZAI_ENDPOINTS[2][1] # coding-global captured = {} def fake_choice(choices, *, default=0, title=""): captured["default"] = default captured["choices"] = choices return default with patch("hermes_cli.main._prompt_provider_choice", side_effect=fake_choice): result = _select_zai_endpoint(coding_url) # Default should point at index 2 (coding-global) assert captured["default"] == 2 assert result == coding_url