From 26f8b790c9cc05da57c1aa2c187dd50fca5d5b80 Mon Sep 17 00:00:00 2001 From: StefanIsMe <130151819+StefanIsMe@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:47:08 +0700 Subject: [PATCH] fix(setup): persist provider when switching model endpoints --- hermes_cli/setup.py | 34 +++- tests/hermes_cli/test_setup_model_provider.py | 165 ++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_setup_model_provider.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5fd2950c9..4a27339ce 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -697,6 +697,12 @@ def setup_model_provider(config: dict): active_oauth = get_active_provider() existing_custom = get_env_value("OPENAI_BASE_URL") + model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} + current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None + if current_config_provider == "auto": + current_config_provider = None + current_config_base_url = str(model_cfg.get("base_url") or "").strip() + # Detect credentials from other CLI tools detected_creds = detect_external_credentials() if detected_creds: @@ -709,10 +715,23 @@ def setup_model_provider(config: dict): print() # Detect if any provider is already configured - has_any_provider = bool(active_oauth or existing_custom or existing_or) + has_any_provider = bool( + current_config_provider or active_oauth or existing_custom or existing_or + ) # Build "keep current" label - if active_oauth and active_oauth in PROVIDER_REGISTRY: + if current_config_provider == "custom": + custom_label = current_config_base_url or existing_custom + keep_label = ( + f"Keep current (Custom: {custom_label})" + if custom_label + else "Keep current (Custom)" + ) + elif current_config_provider == "openrouter": + keep_label = "Keep current (OpenRouter)" + elif current_config_provider and current_config_provider in PROVIDER_REGISTRY: + keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})" + elif active_oauth and active_oauth in PROVIDER_REGISTRY: keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" elif existing_custom: keep_label = f"Keep current (Custom: {existing_custom})" @@ -1215,6 +1234,17 @@ def setup_model_provider(config: dict): _set_model_provider(config, "anthropic") # else: provider_idx == 9 (Keep current) — only shown when a provider already exists + # Normalize "keep current" to an explicit provider so downstream logic + # doesn't fall back to the generic OpenRouter/static-model path. + if selected_provider is None: + if current_config_provider: + selected_provider = current_config_provider + elif active_oauth and active_oauth in PROVIDER_REGISTRY: + selected_provider = active_oauth + elif existing_custom: + selected_provider = "custom" + elif existing_or: + selected_provider = "openrouter" # ── OpenRouter API Key for tools (if not already set) ── # Tools (vision, web, MoA) use OpenRouter independently of the main provider. diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py new file mode 100644 index 000000000..f7c3ce385 --- /dev/null +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -0,0 +1,165 @@ +"""Regression tests for interactive setup provider/model persistence.""" + +from __future__ import annotations + +from hermes_cli.config import load_config, save_config, save_env_value +from hermes_cli.setup import setup_model_provider + + +def _read_env(home): + env_path = home / ".env" + data = {} + if not env_path.exists(): + return data + for line in env_path.read_text().splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + data[k] = v + return data + + +def _clear_provider_env(monkeypatch): + for key in ( + "HERMES_INFERENCE_PROVIDER", + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "ANTHROPIC_TOKEN", + "ANTHROPIC_API_KEY", + ): + monkeypatch.delenv(key, raising=False) + + +def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch): + """Keep-current custom should not fall through to the generic model menu.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + config["model"] = { + "default": "custom/model", + "provider": "custom", + "base_url": "https://example.invalid/v1", + } + save_config(config) + + calls = {"count": 0} + + def fake_prompt_choice(_question, choices, default=0): + calls["count"] += 1 + if calls["count"] == 1: + assert choices[-1] == "Keep current (Custom: https://example.invalid/v1)" + return len(choices) - 1 + raise AssertionError("Model menu should not appear for keep-current custom") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + assert reloaded["model"]["provider"] == "custom" + assert reloaded["model"]["default"] == "custom/model" + assert reloaded["model"]["base_url"] == "https://example.invalid/v1" + assert calls["count"] == 1 + + +def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch): + """Keep-current should respect config-backed providers, not fall back to OpenRouter.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + config["model"] = { + "default": "claude-opus-4-6", + "provider": "anthropic", + } + save_config(config) + + captured = {"provider_choices": None, "model_choices": None} + calls = {"count": 0} + + def fake_prompt_choice(_question, choices, default=0): + calls["count"] += 1 + if calls["count"] == 1: + captured["provider_choices"] = list(choices) + assert choices[-1] == "Keep current (Anthropic)" + return len(choices) - 1 + if calls["count"] == 2: + captured["model_choices"] = list(choices) + return len(choices) - 1 # keep current model + raise AssertionError("Unexpected extra prompt_choice call") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + + setup_model_provider(config) + save_config(config) + + assert captured["provider_choices"] is not None + assert captured["model_choices"] is not None + assert captured["model_choices"][0] == "claude-opus-4-6" + assert "anthropic/claude-opus-4.6 (recommended)" not in captured["model_choices"] + assert calls["count"] == 2 + + +def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch): + """Switching from custom to Codex should clear custom endpoint overrides.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1") + save_env_value("OPENAI_API_KEY", "sk-custom") + save_env_value("OPENROUTER_API_KEY", "sk-or") + + config = load_config() + config["model"] = { + "default": "custom/model", + "provider": "custom", + "base_url": "https://example.invalid/v1", + } + save_config(config) + + picks = iter([1, 0]) + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: next(picks)) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None) + monkeypatch.setattr( + "hermes_cli.auth.resolve_codex_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-access-token", + }, + ) + monkeypatch.setattr( + "hermes_cli.codex_models.get_codex_model_ids", + lambda **kwargs: ["openai/gpt-5.3-codex", "openai/gpt-5-codex-mini"], + ) + + setup_model_provider(config) + save_config(config) + + env = _read_env(tmp_path) + reloaded = load_config() + + assert env.get("OPENAI_BASE_URL") == "" + assert env.get("OPENAI_API_KEY") == "" + assert reloaded["model"]["provider"] == "openai-codex" + assert reloaded["model"]["default"] == "openai/gpt-5.3-codex" + assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"