import importlib import sys import types from contextlib import nullcontext from types import SimpleNamespace import pytest from hermes_cli.auth import AuthError from hermes_cli import main as hermes_main import hermes_cli.main_provider_setup as hermes_cli_main_provider_setup from hermes_cli import model_switch # --------------------------------------------------------------------------- # Module isolation: _import_cli() wipes tools.* / cli / run_agent from # sys.modules so it can re-import cli fresh. Without cleanup the wiped # modules leak into subsequent tests, breaking # mock patches that target "tools.file_tools._get_file_ops" etc. # --------------------------------------------------------------------------- def _reset_modules(prefixes: tuple[str, ...]): for name in list(sys.modules): if any(name == p or name.startswith(p + ".") for p in prefixes): sys.modules.pop(name, None) @pytest.fixture(autouse=True) def _restore_cli_and_tool_modules(): """Save and restore tools/cli/run_agent modules around every test.""" prefixes = ("tools", "cli", "run_agent") original_modules = { name: module for name, module in sys.modules.items() if any(name == p or name.startswith(p + ".") for p in prefixes) } try: yield finally: _reset_modules(prefixes) sys.modules.update(original_modules) def _install_prompt_toolkit_stubs(): class _Dummy: def __init__(self, *args, **kwargs): pass class _Condition: def __init__(self, func): self.func = func def __bool__(self): return bool(self.func()) class _ANSI(str): pass root = types.ModuleType("prompt_toolkit") history = types.ModuleType("prompt_toolkit.history") styles = types.ModuleType("prompt_toolkit.styles") patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout") application = types.ModuleType("prompt_toolkit.application") layout = types.ModuleType("prompt_toolkit.layout") processors = types.ModuleType("prompt_toolkit.layout.processors") filters = types.ModuleType("prompt_toolkit.filters") dimension = types.ModuleType("prompt_toolkit.layout.dimension") menus = types.ModuleType("prompt_toolkit.layout.menus") widgets = types.ModuleType("prompt_toolkit.widgets") key_binding = types.ModuleType("prompt_toolkit.key_binding") completion = types.ModuleType("prompt_toolkit.completion") formatted_text = types.ModuleType("prompt_toolkit.formatted_text") history.FileHistory = _Dummy styles.Style = _Dummy patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext() application.Application = _Dummy layout.Layout = _Dummy layout.HSplit = _Dummy layout.Window = _Dummy layout.FormattedTextControl = _Dummy layout.ConditionalContainer = _Dummy processors.Processor = _Dummy processors.Transformation = _Dummy processors.PasswordProcessor = _Dummy processors.ConditionalProcessor = _Dummy filters.Condition = _Condition dimension.Dimension = _Dummy menus.CompletionsMenu = _Dummy widgets.TextArea = _Dummy key_binding.KeyBindings = _Dummy completion.Completer = _Dummy completion.Completion = _Dummy formatted_text.ANSI = _ANSI root.print_formatted_text = lambda *args, **kwargs: None sys.modules.setdefault("prompt_toolkit", root) sys.modules.setdefault("prompt_toolkit.history", history) sys.modules.setdefault("prompt_toolkit.styles", styles) sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout) sys.modules.setdefault("prompt_toolkit.application", application) sys.modules.setdefault("prompt_toolkit.layout", layout) sys.modules.setdefault("prompt_toolkit.layout.processors", processors) sys.modules.setdefault("prompt_toolkit.filters", filters) sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension) sys.modules.setdefault("prompt_toolkit.layout.menus", menus) sys.modules.setdefault("prompt_toolkit.widgets", widgets) sys.modules.setdefault("prompt_toolkit.key_binding", key_binding) sys.modules.setdefault("prompt_toolkit.completion", completion) sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text) def _import_cli(): for name in list(sys.modules): if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."): sys.modules.pop(name, None) if "firecrawl" not in sys.modules: sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object) try: importlib.import_module("prompt_toolkit") except ModuleNotFoundError: _install_prompt_toolkit_stubs() return importlib.import_module("cli") def test_provider_flag_uses_named_custom_default_model(monkeypatch): """`--provider ` without `-m` uses that entry's default_model (#86978).""" cli = _import_cli() monkeypatch.setitem( cli.CLI_CONFIG, "model", {"default": "tencent/hy3:free", "provider": "nous"}, ) config = { "model": {"default": "tencent/hy3:free", "provider": "nous"}, "providers": { "gmk-lan": { "name": "GMK Local", "base_url": "http://gmk.lan:9931/v1", "api_key": "not-needed", "default_model": "/models/gemma.gguf", } }, } monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config) shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1) assert shell.model == "/models/gemma.gguf" assert shell.requested_provider == "gmk-lan" def test_explicit_model_wins_over_provider_default_model(monkeypatch): """`-m` still wins when `--provider` also names a custom default_model.""" cli = _import_cli() monkeypatch.setitem( cli.CLI_CONFIG, "model", {"default": "tencent/hy3:free", "provider": "nous"}, ) config = { "model": {"default": "tencent/hy3:free", "provider": "nous"}, "providers": { "gmk-lan": { "name": "GMK Local", "base_url": "http://gmk.lan:9931/v1", "api_key": "not-needed", "default_model": "/models/gemma.gguf", } }, } monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config) shell = cli.HermesCLI( provider="gmk-lan", model="explicit-id", compact=True, max_turns=1, ) assert shell.model == "explicit-id" @pytest.mark.parametrize( ("explicit_base_url", "expected_base_url"), [ (None, "http://alias.example:8000/v1"), ("http://override.example:9000/v1", "http://override.example:9000/v1"), ], ) def test_startup_alias_base_url_reaches_runtime_resolution( monkeypatch, explicit_base_url, expected_base_url, ): """Startup aliases keep their endpoint unless --base-url overrides it (#103933).""" cli = _import_cli() monkeypatch.setitem( cli.CLI_CONFIG, "model", { "default": "fallback-model", "provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", }, ) monkeypatch.setattr( model_switch, "DIRECT_ALIASES", { "myalias": model_switch.DirectAlias( "my-model-id", "custom", "http://alias.example:8000/v1", api_key="not-needed", ), }, ) shell = cli.HermesCLI( model="myalias", base_url=explicit_base_url, compact=True, max_turns=1, ) assert shell._ensure_runtime_credentials() is True assert shell.model == "my-model-id" assert shell.provider == "custom" assert shell.base_url == expected_base_url def test_provider_flag_logs_when_custom_default_model_cannot_resolve(monkeypatch, caplog): """A named --provider that fails to resolve must not fail silently.""" cli = _import_cli() monkeypatch.setitem( cli.CLI_CONFIG, "model", {"default": "tencent/hy3:free", "provider": "nous"}, ) def _boom(_name): raise RuntimeError("catalog unavailable") monkeypatch.setattr( "hermes_cli.runtime_provider._get_named_custom_provider", _boom, ) with caplog.at_level("WARNING"): shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1) assert shell.model == "tencent/hy3:free" assert any( "gmk-lan" in rec.getMessage() and "catalog unavailable" in rec.getMessage() for rec in caplog.records ) def test_runtime_resolution_failure_is_not_sticky(monkeypatch): cli = _import_cli() calls = {"count": 0} def _runtime_resolve(**kwargs): calls["count"] += 1 if calls["count"] == 1: raise RuntimeError("temporary auth failure") return { "provider": "openrouter", "api_mode": "chat_completions", "base_url": "https://openrouter.ai/api/v1", "api_key": "test-key", "source": "env/config", } class _DummyAgent: def __init__(self, *args, **kwargs): self.kwargs = kwargs monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) monkeypatch.setattr("run_agent.AIAgent", _DummyAgent) shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1) assert shell._init_agent() is False assert shell._init_agent() is True assert calls["count"] == 2 assert shell.agent is not None def test_ensure_runtime_credentials_passes_cli_model_as_target_model(monkeypatch): """`hermes -m mimo-v2.5 --provider opencode-go` must resolve credentials for the model the CLI will send: the Zen/Go rungs key off the effective model, and without target_model a `*-free` config default decides the api_mode/base_url for an explicit paid model (#112600).""" cli = _import_cli() seen = {} def _runtime_resolve(**kwargs): seen.update(kwargs) return { "provider": "opencode-go", "api_mode": "chat_completions", "base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key", "source": "env", } monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) shell = cli.HermesCLI(model="mimo-v2.5", provider="opencode-go", compact=True, max_turns=1) assert shell._ensure_runtime_credentials() is True assert seen["requested"] == "opencode-go" assert seen["target_model"] == "mimo-v2.5" def test_fallback_runtime_resolves_the_fallback_entry_model(monkeypatch, tmp_path): """The auth-fallback rung must resolve credentials for the ENTRY's model, exactly like the primary path does for `-m`: a `*-free` config default must not decide the api_mode/base_url a Go-only fallback entry is built with (#112600).""" from hermes_cli.auth import AuthError from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin home = tmp_path / "hermes" home.mkdir() (home / "config.yaml").write_text( "model:\n default: mimo-v2.5-free\n provider: opencode\n base_url: https://opencode.ai/zen/v1\n") monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test-go") monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False) shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin) shell._fallback_model = [{"provider": "opencode-go", "model": "mimo-v2.5"}] runtime = shell._resolve_fallback_runtime(AuthError("no key", provider="opencode-zen", code="missing_api_key")) assert runtime is not None assert shell.model == "mimo-v2.5" assert runtime["base_url"] == "https://opencode.ai/zen/go/v1" def _quota_auth_error(): from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, AuthError return AuthError( "Codex provider quota exhausted (429); retry after 1839s. Credentials are still valid.", provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, relogin_required=False, ) @pytest.mark.parametrize(("exc_factory", "expected", "absent"), [ (_quota_auth_error, "quota exhausted", "auth failed"), (lambda: __import__("hermes_cli.auth", fromlist=["AuthError"]).AuthError( "no key", provider="openai-codex", code="missing_api_key"), "Primary auth failed", "quota exhausted"), ]) def test_fallback_runtime_labels_quota_outage_and_bad_credentials_distinctly(monkeypatch, tmp_path, exc_factory, expected, absent): """A 429 at credential resolution is quota, not bad credentials (#117482); a real credential failure keeps the auth-failed wording.""" from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin home = tmp_path / "hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) printed = [] monkeypatch.setattr("cli._cprint", printed.append, raising=False) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **kw: {"provider": "custom", "base_url": "http://x/v1", "api_key": "k"}, ) monkeypatch.setattr("hermes_cli.fallback_config.resolve_entry_api_key", lambda entry: "k") shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin) shell._fallback_model = [{"provider": "custom", "model": "local-model"}] runtime = shell._resolve_fallback_runtime(exc_factory()) assert runtime is not None assert printed assert expected in printed[-1] assert absent not in printed[-1] def test_ensure_runtime_credentials_records_quota_vs_bad_key(monkeypatch, tmp_path): """Kanban workers need this flag: a quota wall at startup is not a worker failure (#117482).""" from hermes_cli.auth import AuthError from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin home = tmp_path / "hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False) def _raise_quota(**kw): raise _quota_auth_error() monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_quota) quota_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin) quota_shell.model = "gpt-x" quota_shell.requested_provider = "openai-codex" quota_shell._explicit_api_key = None quota_shell._explicit_base_url = None quota_shell._fallback_model = [] quota_shell.tool_progress_mode = "off" assert quota_shell._ensure_runtime_credentials() is False assert quota_shell._credentials_rate_limited is True def _raise_missing(**kw): raise AuthError("no key", provider="openai-codex", code="missing_api_key") monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_missing) bad_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin) bad_shell.model = "gpt-x" bad_shell.requested_provider = "openai-codex" bad_shell._explicit_api_key = None bad_shell._explicit_base_url = None bad_shell._fallback_model = [] bad_shell.tool_progress_mode = "off" assert bad_shell._ensure_runtime_credentials() is False assert bad_shell._credentials_rate_limited is False def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch): import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(config_home)) config_path = config_home / "config.yaml" config_path.write_text( yaml.safe_dump( { "model": { "provider": "custom", "default": "glm-5.2", "base_url": "https://api.neuralwatt.com/v1", "api_key": "${NEURALWATT_API_KEY}", "api_mode": "chat_completions", } }, sort_keys=False, ) ) stale_config = yaml.safe_load(config_path.read_text()) or {} selected_model = "deepseek/deepseek-v4-flash" monkeypatch.setattr( "hermes_cli.auth.get_provider_auth_state", lambda provider: { "access_token": "nous-token", "portal_base_url": "https://portal.example.com", }, ) monkeypatch.setattr( "hermes_cli.auth.resolve_nous_runtime_credentials", lambda *args, **kwargs: { "base_url": "https://inference-api.nousresearch.com/v1", "api_key": "nous-key", }, ) monkeypatch.setattr( "hermes_cli.models.get_curated_nous_model_ids", lambda: [selected_model], ) monkeypatch.setattr("hermes_cli.models_pricing.get_pricing_for_provider", lambda provider: {}) monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda **kwargs: False) monkeypatch.setattr( "hermes_cli.models.union_with_portal_paid_recommendations", lambda model_ids, pricing, portal_url: (model_ids, pricing), ) monkeypatch.setattr( "hermes_cli.auth._prompt_model_selection", lambda *args, **kwargs: selected_model, ) monkeypatch.setattr( "hermes_cli.nous_subscription.prompt_enable_tool_gateway", lambda config: None, ) hermes_main._model_flow_nous(stale_config, current_model="glm-5.2") config = yaml.safe_load(config_path.read_text()) or {} model = config.get("model") assert model["provider"] == "nous" assert model["default"] == selected_model assert model["base_url"] == "https://inference-api.nousresearch.com/v1" assert "api_key" not in model assert "api_mode" not in model def _seed_stale_custom_model(tmp_path, monkeypatch): import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(config_home)) config_path = config_home / "config.yaml" config_path.write_text( yaml.safe_dump( { "model": { "provider": "custom", "default": "glm-5.2", "base_url": "https://api.neuralwatt.com/v1", "api_key": "${NEURALWATT_API_KEY}", "api": "legacy-stale-key", "api_mode": "anthropic_messages", } }, sort_keys=False, ) ) (config_home / ".env").write_text("") return config_path def test_codex_provider_uses_config_model(monkeypatch): """Model comes from config.yaml, not LLM_MODEL env var. Config.yaml is the single source of truth to avoid multi-agent conflicts.""" cli = _import_cli() # LLM_MODEL env var should be IGNORED (even if set) monkeypatch.setenv("LLM_MODEL", "should-be-ignored") monkeypatch.delenv("OPENAI_MODEL", raising=False) # Set model via config monkeypatch.setitem(cli.CLI_CONFIG, "model", { "default": "gpt-5.2-codex", "provider": "openai-codex", "base_url": "https://chatgpt.com/backend-api/codex", }) def _runtime_resolve(**kwargs): return { "provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", "api_key": "fake-codex-token", "source": "env/config", } monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) # Prevent live API call from overriding the config model monkeypatch.setattr( "hermes_cli.codex_models.get_codex_model_ids", lambda access_token=None: ["gpt-5.2-codex"], ) shell = cli.HermesCLI(compact=True, max_turns=1) assert shell._ensure_runtime_credentials() is True assert shell.provider == "openai-codex" # Model from config (may be normalized by codex provider logic) assert "codex" in shell.model.lower() # LLM_MODEL env var is NOT used assert shell.model != "should-be-ignored" @pytest.mark.parametrize( ("reasoning_flag", "expected_effort"), [(None, "high"), ("low", "low")], ids=["fallback_model_override_applies", "explicit_cli_flag_outranks"], ) def test_startup_fallback_re_resolves_reasoning_for_the_fallback_model(monkeypatch, reasoning_flag, expected_effort): """Startup auth fallback swaps the model, so the CLI-level reasoning_config must follow it (per-model override for the fallback model, not the launch model's effort); an explicit ``--reasoning`` is the user's intent for this run and survives the swap.""" cli = _import_cli() monkeypatch.setattr(cli, "_cprint", lambda *a, **k: None) monkeypatch.setitem(cli.CLI_CONFIG, "model", {"default": "primary-model", "provider": "openai-codex"}) monkeypatch.setitem(cli.CLI_CONFIG, "fallback_providers", [{"provider": "zai", "model": "glm-5.3-flash"}]) monkeypatch.setitem(cli.CLI_CONFIG, "agent", { **cli.CLI_CONFIG.get("agent", {}), "reasoning_effort": "medium", "reasoning_overrides": {"glm-5.3-flash": "high"}}) def _runtime_resolve(requested=None, **kwargs): if requested == "openai-codex": raise AuthError("quota exhausted", provider="openai-codex", code="auth_failed") return {"provider": "zai", "api_mode": "chat_completions", "base_url": "https://api.z.ai/api/coding/paas/v4", "api_key": "sk-zai", "source": "env"} monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) shell = cli.HermesCLI(compact=True, max_turns=1, reasoning=reasoning_flag) assert shell.reasoning_config["effort"] == ("medium" if reasoning_flag is None else reasoning_flag) assert shell._ensure_runtime_credentials() is True assert (shell.model, shell.provider) == ("glm-5.3-flash", "zai") assert shell.reasoning_config["effort"] == expected_effort def test_custom_entry_model_swap_re_resolves_reasoning(monkeypatch): """`hermes chat --model `: the runtime's explicit `model` replaces the slug, so the CLI-level reasoning_config must follow to that model's per-model override.""" cli = _import_cli() monkeypatch.setattr(cli, "_cprint", lambda *a, **k: None) monkeypatch.setitem(cli.CLI_CONFIG, "agent", { **cli.CLI_CONFIG.get("agent", {}), "reasoning_effort": "medium", "reasoning_overrides": {"real-model": "high"}}) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **kw: {"provider": "custom", "name": "my-lan", "model": "real-model", "api_mode": "chat_completions", "base_url": "http://10.0.0.7:11434/v1", "api_key": "sk-lan", "source": "custom"}) shell = cli.HermesCLI(model="my-lan", compact=True, max_turns=1) assert shell.reasoning_config["effort"] == "medium" assert shell._ensure_runtime_credentials() is True assert shell.model == "real-model" assert shell.reasoning_config["effort"] == "high" def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch): monkeypatch.setattr( "hermes_cli.config.get_env_value", lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "", ) saved_env = {} monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value)) monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model)) monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None) monkeypatch.setattr("hermes_cli.main_provider_setup._save_custom_provider", lambda *args, **kwargs: None) monkeypatch.setattr( "hermes_cli.models.probe_api_models", lambda api_key, base_url: { "models": ["llm"], "probed_url": "http://localhost:8000/v1/models", "resolved_base_url": "http://localhost:8000/v1", "suggested_base_url": "http://localhost:8000/v1", "used_fallback": True, }, ) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}}, ) monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) # After the probe detects a single model ("llm"), the flow asks # "Use this model? [Y/n]:" — confirm with Enter, then context length, # then display name. The api_mode prompt also runs before model selection. answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers)) caller_cfg = {} hermes_main._model_flow_custom(caller_cfg) assert caller_cfg["model"]["base_url"] == "http://localhost:8000/v1" # OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative assert "OPENAI_BASE_URL" not in saved_env assert saved_env["MODEL"] == "llm" def test_model_flow_custom_persists_selected_api_mode(monkeypatch): saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}} captured_provider = {} monkeypatch.setattr( "hermes_cli.config.get_env_value", lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "", ) monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None) monkeypatch.setattr( "hermes_cli.models.probe_api_models", lambda api_key, base_url: { "models": [], "probed_url": f"{base_url.rstrip('/')}/models", "resolved_base_url": None, "suggested_base_url": None, "used_fallback": False, }, ) saved_env = {} monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg) monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg)) monkeypatch.setattr( "hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value), ) monkeypatch.setattr( "hermes_cli.main_provider_setup._save_custom_provider", lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="": captured_provider.update( { "base_url": base_url, "api_key": api_key, "model": model, "context_length": context_length, "name": name, "api_mode": api_mode, "key_env": key_env, } ), ) answers = iter( [ "https://codex.example.com/v1", "3", "chosen-model", "", "", ] ) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": "test-key") hermes_main._model_flow_custom({"model": {"provider": "custom"}}) assert saved_cfg["model"]["provider"] == "custom" assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1" assert saved_cfg["model"]["api_mode"] == "codex_responses" assert captured_provider["api_mode"] == "codex_responses" # The key itself goes to .env; config.yaml only references it (#69449). key_env = captured_provider["key_env"] assert saved_cfg["model"]["api_key"] == f"${{{key_env}}}" assert saved_env[key_env] == "test-key" def test_cmd_model_forwards_nous_login_tls_options(monkeypatch): monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "gpt-5", "provider": "nous"}}, ) monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "") monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None) monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous") monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None) monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0) monkeypatch.setattr(hermes_cli_main_provider_setup, "_prompt_provider_choice", lambda choices, **kwargs: 0) captured = {} def _fake_login(login_args, provider_config): captured["portal_url"] = login_args.portal_url captured["inference_url"] = login_args.inference_url captured["client_id"] = login_args.client_id captured["scope"] = login_args.scope captured["no_browser"] = login_args.no_browser captured["timeout"] = login_args.timeout captured["ca_bundle"] = login_args.ca_bundle captured["insecure"] = login_args.insecure monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login) hermes_main.cmd_model( SimpleNamespace( portal_url="https://portal.nousresearch.com", inference_url="https://inference.nousresearch.com/v1", client_id="hermes-local", scope="openid profile", no_browser=True, timeout=7.5, ca_bundle="/tmp/local-ca.pem", insecure=True, ) ) assert captured == { "portal_url": "https://portal.nousresearch.com", "inference_url": "https://inference.nousresearch.com/v1", "client_id": "hermes-local", "scope": "openid profile", "no_browser": True, "timeout": 7.5, "ca_bundle": "/tmp/local-ca.pem", "insecure": True, } # --------------------------------------------------------------------------- # _auto_provider_name — unit tests # --------------------------------------------------------------------------- def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): """When a display name is passed, it should appear in the saved entry.""" import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, ) saved = {} def _save(cfg): saved.update(cfg) monkeypatch.setattr("hermes_cli.config.save_config", _save) _save_custom_provider("http://localhost:11434/v1", name="Ollama") entries = saved.get("custom_providers", []) assert len(entries) == 1 assert entries[0]["name"] == "Ollama" def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path): """With key_env set the entry must not carry the secret (#69449).""" import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, ) saved = {} monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg)) _save_custom_provider( "http://localhost:11434/v1", api_key="sk-secret", name="Ollama", key_env="HERMES_CUSTOM_LOCALHOST_11434_API_KEY", ) entry = saved["custom_providers"][0] assert entry["key_env"] == "HERMES_CUSTOM_LOCALHOST_11434_API_KEY" assert "api_key" not in entry assert "sk-secret" not in yaml.safe_dump(saved) def test_custom_endpoint_key_env_is_a_valid_posix_name_for_ip_endpoints(): """Every IP-based local endpoint slugs to a digit-leading name. ``save_env_value`` rejects names that don't match ``[A-Za-z_][A-Za-z0-9_]*``, so deriving ``127_0_0_1_8080_API_KEY`` would raise on exactly the local-proxy setups this is meant to protect. The fixed prefix makes the result valid by construction. """ from hermes_cli.config import _ENV_VAR_NAME_RE, custom_endpoint_key_env for identity in ("127.0.0.1_8080", "0.0.0.0", "10.0.0.7:11434", "", "-–-"): assert _ENV_VAR_NAME_RE.match(custom_endpoint_key_env(identity)), identity