"""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. """ import os 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) 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" def test_dict_model_stays_dict(self, config_home): """When config.model is already a dict, _save_model_choice preserves it.""" import yaml (config_home / "config.yaml").write_text( "model:\n default: old-model\n provider: openrouter\n" ) from hermes_cli.auth import _save_model_choice _save_model_choice("new-model") config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict) assert model["default"] == "new-model" assert model["provider"] == "openrouter" # preserved class TestProviderPersistsAfterModelSave: 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" def test_copilot_provider_saved_when_selected(self, config_home): """_model_flow_copilot should persist provider/base_url/model together.""" from hermes_cli.main import _model_flow_copilot from hermes_cli.config import load_config with patch( "hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ "provider": "copilot", "api_key": "gh-cli-token", "base_url": "https://api.githubcopilot.com", "source": "gh auth token", }, ), patch( "hermes_cli.models.fetch_github_model_catalog", return_value=[ { "id": "gpt-4.1", "capabilities": {"type": "chat", "supports": {}}, "supported_endpoints": ["/chat/completions"], }, { "id": "gpt-5.4", "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}, "supported_endpoints": ["/responses"], }, ], ), patch( "hermes_cli.auth._prompt_model_selection", return_value="gpt-5.4", ), patch( "hermes_cli.main._prompt_reasoning_effort_selection", return_value="high", ), patch( "hermes_cli.auth.deactivate_provider", ): _model_flow_copilot(load_config(), "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") == "copilot" assert model.get("base_url") == "https://api.githubcopilot.com" assert model.get("default") == "gpt-5.4" assert model.get("api_mode") == "codex_responses" assert config["agent"]["reasoning_effort"] == "high" def test_copilot_acp_provider_saved_when_selected(self, config_home): """_model_flow_copilot_acp should persist provider/base_url/model together.""" from hermes_cli.main import _model_flow_copilot_acp from hermes_cli.config import load_config with patch( "hermes_cli.auth.get_external_process_provider_status", return_value={ "resolved_command": "/usr/local/bin/copilot", "command": "copilot", "base_url": "acp://copilot", }, ), patch( "hermes_cli.auth.resolve_external_process_provider_credentials", return_value={ "provider": "copilot-acp", "api_key": "copilot-acp", "base_url": "acp://copilot", "command": "/usr/local/bin/copilot", "args": ["--acp", "--stdio"], "source": "process", }, ), patch( "hermes_cli.auth.resolve_api_key_provider_credentials", return_value={ "provider": "copilot", "api_key": "gh-cli-token", "base_url": "https://api.githubcopilot.com", "source": "gh auth token", }, ), patch( "hermes_cli.models.fetch_github_model_catalog", return_value=[ { "id": "gpt-4.1", "capabilities": {"type": "chat", "supports": {}}, "supported_endpoints": ["/chat/completions"], }, { "id": "gpt-5.4", "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}, "supported_endpoints": ["/responses"], }, ], ), patch( "hermes_cli.auth._prompt_model_selection", return_value="gpt-5.4", ), patch( "hermes_cli.auth.deactivate_provider", ): _model_flow_copilot_acp(load_config(), "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") == "copilot-acp" assert model.get("base_url") == "acp://copilot" assert model.get("default") == "gpt-5.4" assert model.get("api_mode") == "chat_completions"