hermes-agent/tests/cli/test_cli_provider_resolution.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

595 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# ---------------------------------------------------------------------------
# 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_hermes_cli_init_does_not_eagerly_resolve_runtime_provider(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _unexpected_runtime_resolve(**kwargs):
calls["count"] += 1
raise AssertionError("resolve_runtime_provider should not be called in HermesCLI.__init__")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _unexpected_runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell is not None
assert calls["count"] == 0
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(cli, "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_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
cli = _import_cli()
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
shell.provider = "openrouter"
shell.api_mode = "chat_completions"
shell.base_url = "https://openrouter.ai/api/v1"
shell.api_key = "sk-primary"
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
assert result["model"] == "gpt-5"
assert result["runtime"]["provider"] == "openrouter"
def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch):
import 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.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 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"
def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
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._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))
hermes_main._model_flow_custom({})
output = capsys.readouterr().out
assert "Saving the working base URL instead" in output
assert "Detected model: llm" in output
# 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._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)
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_auto_provider_name_localhost():
from hermes_cli.main import _auto_provider_name
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
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 yaml
from hermes_cli.main import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.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 yaml
from hermes_cli.main import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.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.
"""
import re
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