hermes-agent/tests/cli/test_cli_provider_resolution.py
Brooklyn Nicholson 15f29d0b6f test: cover custom endpoint key storage and model-list persistence
Bug-class coverage for both fixes: the full catalogue survives Save, context
lengths are preserved, the key never lands in config.yaml on either write
path, blank clears it, a pre-fix plaintext key migrates while a ${VAR}
template is left alone, two endpoints on one host keep separate credentials,
and an IP-derived name is still a valid POSIX env var.

The two delete tests asserted on the plaintext mirror; they now assert the
same invariants against the credential reference.
2026-07-24 21:12:59 -05:00

1013 lines
38 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_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch):
cli = _import_cli()
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://same-endpoint.example/v1",
"api_key": "same-key",
"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))
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
shell.provider = "openrouter"
shell.api_mode = "chat_completions"
shell.base_url = "https://same-endpoint.example/v1"
shell.api_key = "same-key"
shell.agent = object()
assert shell._ensure_runtime_credentials() is True
assert shell.agent is None
assert shell.provider == "openai-codex"
assert shell.api_mode == "codex_responses"
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_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
cli = _import_cli()
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
config_copy = dict(cli.CLI_CONFIG)
model_copy = dict(config_copy.get("model", {}))
model_copy["provider"] = "custom"
model_copy["base_url"] = "https://api.fireworks.ai/inference/v1"
config_copy["model"] = model_copy
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
shell = cli.HermesCLI(model="fireworks/minimax-m2p5", compact=True, max_turns=1)
assert shell.requested_provider == "custom"
def test_cli_init_wires_moa_preset_model_to_moa_provider(monkeypatch):
# #56828: constructing the CLI with `-m moa:<preset>` (the -Q one-shot
# path) must strip the prefix off self.model AND force
# requested_provider="moa", so the existing resolve_runtime_provider /
# agent_init MoA route runs non-interactively. The unit tests cover
# _normalize_moa_model() in isolation; this asserts the __init__ wiring
# the sweeper flagged as untested.
cli = _import_cli()
# Neutralize any config/env provider so a failure here can only come from
# the moa override, not an ambient default.
config_copy = dict(cli.CLI_CONFIG)
model_copy = dict(config_copy.get("model", {}))
model_copy["provider"] = None
config_copy["model"] = model_copy
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
shell = cli.HermesCLI(model="moa:strategy", compact=True, max_turns=1)
assert shell.requested_provider == "moa"
assert shell.model == "strategy"
def test_cli_init_moa_prefix_overrides_explicit_provider(monkeypatch):
# The #56828 regression case: `--provider deepseek -m moa:strategy`
# silently dropped MoA because the explicit provider won. __init__ resolves
# requested_provider as `_moa_provider_override or provider or ...`, so the
# moa: prefix must win over the explicit --provider.
cli = _import_cli()
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
shell = cli.HermesCLI(
model="moa:strategy", provider="deepseek", compact=True, max_turns=1
)
assert shell.requested_provider == "moa"
assert shell.model == "strategy"
def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
"""When provider resolves to openai-codex and no model was explicitly
chosen, the global config default (e.g. anthropic/claude-opus-4.6) must
be replaced with a Codex-compatible model. Fixes #651."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# Ensure local user config does not leak a model into the test
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "",
"base_url": "https://openrouter.ai/api/v1",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"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))
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.2-codex", "gpt-5.1-codex-mini"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
assert shell._model_is_default is True
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
assert "anthropic" not in shell.model
assert "claude" not in shell.model
assert shell.model == "gpt-5.2-codex"
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.nous_subscription.managed_nous_tools_enabled",
lambda *args, **kwargs: True,
)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "elevenlabs"},
"browser": {"cloud_provider": "browser-use"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "nous-token"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
out = capsys.readouterr().out
assert "Default model set to:" in out
assert config["tts"]["provider"] == "elevenlabs"
assert config["browser"]["cloud_provider"] == "browser-use"
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_model_flow_openrouter_clears_stale_custom_key(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr(
"hermes_cli.main._prompt_api_key",
lambda *args, **kwargs: ("sk-openrouter", False),
)
monkeypatch.setattr(
"hermes_cli.models.model_ids",
lambda **kwargs: ["anthropic/claude-sonnet-4.6"],
)
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda *a, **k: {})
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "anthropic/claude-sonnet-4.6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_openrouter({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "openrouter"
assert model["default"] == "anthropic/claude-sonnet-4.6"
assert model["api_mode"] == "chat_completions"
assert "api_key" not in model
assert "api" not in model
def test_model_flow_anthropic_clears_stale_custom_key_and_mode(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr("hermes_cli.auth.get_anthropic_key", lambda: "sk-ant-api03-test")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None,
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"hermes_cli.model_setup_flows._prompt_auth_credentials_choice",
lambda title: "use",
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "claude-sonnet-4-6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_anthropic({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "anthropic"
assert model["default"] == "claude-sonnet-4-6"
assert "base_url" not in model
assert "api_key" not in model
assert "api" not in model
assert "api_mode" not in model
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
from hermes_cli.nous_account import NousPortalAccountInfo
# Entitled account (paid → all tools eligible) drives the offer; the prompt
# is a per-tool checklist now, so capture the call rather than scrape stdout.
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda **kwargs: NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
),
)
captured = {}
def _fake_checklist(title, items, pre_selected=None):
captured["title"] = title
captured["items"] = list(items)
return [] # decline; we only assert the prompt was offered
monkeypatch.setattr("hermes_cli.setup.prompt_checklist", _fake_checklist, raising=False)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "***"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "***",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
# The per-tool Tool Gateway checklist was offered.
assert "title" in captured
assert "Tool Gateway" in captured["title"] or "tool pool" in captured["title"].lower()
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_codex_config_model_not_replaced_by_normalization(monkeypatch):
"""When the user sets model.default in config.yaml to a specific codex
model, _normalize_model_for_provider must NOT replace it with the latest
available model from the API. Regression test for #1887."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# User explicitly configured gpt-5.3-codex in config.yaml
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "gpt-5.3-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-key",
"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))
# API returns a DIFFERENT model than what the user configured
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
# Config model is NOT the global default — user made a deliberate choice
assert shell._model_is_default is False
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
# Model must stay as user configured, not replaced by gpt-5.4
assert shell.model == "gpt-5.3-codex"
def test_codex_provider_preserves_explicit_codex_model(monkeypatch):
"""If the user explicitly passes a Codex-compatible model, it must be
preserved even when the provider resolves to openai-codex."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"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))
shell = cli.HermesCLI(model="gpt-5.1-codex-mini", compact=True, max_turns=1)
assert shell._model_is_default is False
assert shell._ensure_runtime_credentials() is True
assert shell.model == "gpt-5.1-codex-mini"
def test_codex_provider_strips_provider_prefix_from_model(monkeypatch):
"""openai/gpt-5.3-codex should become gpt-5.3-codex — the Codex
Responses API does not accept provider-prefixed model slugs."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"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))
shell = cli.HermesCLI(model="openai/gpt-5.3-codex", compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert shell.model == "gpt-5.3-codex"
def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "invalid-provider"}},
)
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)
def _resolve_provider(requested, **kwargs):
if requested == "invalid-provider":
raise AuthError("Unknown provider 'invalid-provider'.", code="invalid_provider")
return "openrouter"
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: len(choices) - 1)
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())
hermes_main.cmd_model(SimpleNamespace())
output = capsys.readouterr().out
assert "Warning:" in output
assert "falling back to auto provider detection" in output.lower()
assert "No change." in output
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_auto_provider_name_runpod():
from hermes_cli.main import _auto_provider_name
assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1")
def test_auto_provider_name_remote():
from hermes_cli.main import _auto_provider_name
result = _auto_provider_name("https://api.together.xyz/v1")
assert result == "Api.together.xyz"
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_save_custom_provider_migrates_an_existing_plaintext_entry(monkeypatch, tmp_path):
"""Re-saving a known URL swaps its inline key for the .env reference."""
import yaml
from hermes_cli.main import _save_custom_provider
existing = {
"custom_providers": [
{
"name": "Ollama",
"base_url": "http://localhost:11434/v1",
"api_key": "sk-legacy",
}
]
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: existing)
saved = {}
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
_save_custom_provider(
"http://localhost:11434/v1",
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
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
def test_custom_endpoint_key_env_separates_ports_on_one_host():
"""Two servers on one machine must not collapse onto one .env slot."""
from hermes_cli.config import custom_endpoint_key_env
assert custom_endpoint_key_env("127.0.0.1_8000") != custom_endpoint_key_env("127.0.0.1_8001")
assert custom_endpoint_key_env("acme") == custom_endpoint_key_env("ACME")