refactor(tests): re-architect tests + fix CI failures (#5946)
* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
This commit is contained in:
52
tests/hermes_cli/test_anthropic_oauth_flow.py
Normal file
52
tests/hermes_cli/test_anthropic_oauth_flow.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for Anthropic OAuth setup flow behavior."""
|
||||
|
||||
from hermes_cli.config import load_env, save_env_value
|
||||
|
||||
|
||||
def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.run_oauth_setup_token",
|
||||
lambda: "sk-ant-oat01-from-claude-setup",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {
|
||||
"accessToken": "cc-access-token",
|
||||
"refreshToken": "cc-refresh-token",
|
||||
"expiresAt": 9999999999999,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: True,
|
||||
)
|
||||
|
||||
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||
|
||||
save_env_value("ANTHROPIC_TOKEN", "stale-env-token")
|
||||
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
output = capsys.readouterr().out
|
||||
assert "Claude Code credentials linked" in output
|
||||
|
||||
|
||||
def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None)
|
||||
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("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
|
||||
monkeypatch.setattr("getpass.getpass", lambda _prompt="": "sk-ant-oat01-manual-token")
|
||||
|
||||
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||
|
||||
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token"
|
||||
output = capsys.readouterr().out
|
||||
assert "Setup-token saved" in output
|
||||
46
tests/hermes_cli/test_anthropic_provider_persistence.py
Normal file
46
tests/hermes_cli/test_anthropic_provider_persistence.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for Anthropic credential persistence helpers."""
|
||||
|
||||
from hermes_cli.config import load_env
|
||||
|
||||
|
||||
def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.config import save_anthropic_oauth_token
|
||||
|
||||
save_anthropic_oauth_token("sk-ant-oat01-test-token")
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-test-token"
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
|
||||
|
||||
def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
|
||||
|
||||
save_anthropic_oauth_token("sk-ant-oat01-token")
|
||||
use_anthropic_claude_code_credentials()
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
|
||||
|
||||
def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.config import save_anthropic_api_key
|
||||
|
||||
save_anthropic_api_key("sk-ant-api03-key")
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key"
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||
964
tests/hermes_cli/test_api_key_providers.py
Normal file
964
tests/hermes_cli/test_api_key_providers.py
Normal file
@@ -0,0 +1,964 @@
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure dotenv doesn't interfere
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
ProviderConfig,
|
||||
resolve_provider,
|
||||
get_api_key_provider_status,
|
||||
resolve_api_key_provider_credentials,
|
||||
get_external_process_provider_status,
|
||||
resolve_external_process_provider_credentials,
|
||||
get_auth_status,
|
||||
AuthError,
|
||||
KIMI_CODE_BASE_URL,
|
||||
_try_gh_cli_token,
|
||||
_resolve_kimi_base_url,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Registry tests
|
||||
# =============================================================================
|
||||
|
||||
class TestProviderRegistry:
|
||||
"""Test that new providers are correctly registered."""
|
||||
|
||||
@pytest.mark.parametrize("provider_id,name,auth_type", [
|
||||
("copilot-acp", "GitHub Copilot ACP", "external_process"),
|
||||
("copilot", "GitHub Copilot", "api_key"),
|
||||
("huggingface", "Hugging Face", "api_key"),
|
||||
("zai", "Z.AI / GLM", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "AI Gateway", "api_key"),
|
||||
("kilocode", "Kilo Code", "api_key"),
|
||||
])
|
||||
def test_provider_registered(self, provider_id, name, auth_type):
|
||||
assert provider_id in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
assert pconfig.name == name
|
||||
assert pconfig.auth_type == auth_type
|
||||
assert pconfig.inference_base_url # must have a default base URL
|
||||
|
||||
def test_zai_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["zai"]
|
||||
assert pconfig.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY")
|
||||
assert pconfig.base_url_env_var == "GLM_BASE_URL"
|
||||
|
||||
def test_copilot_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["copilot"]
|
||||
assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
assert pconfig.base_url_env_var == ""
|
||||
|
||||
def test_kimi_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
||||
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "KIMI_BASE_URL"
|
||||
|
||||
def test_minimax_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["minimax"]
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_BASE_URL"
|
||||
|
||||
def test_minimax_cn_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["minimax-cn"]
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
|
||||
|
||||
def test_ai_gateway_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
||||
assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
|
||||
|
||||
def test_kilocode_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["kilocode"]
|
||||
assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
|
||||
|
||||
def test_huggingface_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["huggingface"]
|
||||
assert pconfig.api_key_env_vars == ("HF_TOKEN",)
|
||||
assert pconfig.base_url_env_var == "HF_BASE_URL"
|
||||
|
||||
def test_base_urls(self):
|
||||
assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
|
||||
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
|
||||
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
|
||||
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
|
||||
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
|
||||
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
||||
assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway"
|
||||
assert PROVIDER_REGISTRY["huggingface"].inference_base_url == "https://router.huggingface.co/v1"
|
||||
|
||||
def test_oauth_providers_unchanged(self):
|
||||
"""Ensure we didn't break the existing OAuth providers."""
|
||||
assert "nous" in PROVIDER_REGISTRY
|
||||
assert PROVIDER_REGISTRY["nous"].auth_type == "oauth_device_code"
|
||||
assert "openai-codex" in PROVIDER_REGISTRY
|
||||
assert PROVIDER_REGISTRY["openai-codex"].auth_type == "oauth_external"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Resolution tests
|
||||
# =============================================================================
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
"NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
|
||||
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_provider_env(monkeypatch):
|
||||
for key in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: {})
|
||||
|
||||
|
||||
class TestResolveProvider:
|
||||
"""Test resolve_provider() with new providers."""
|
||||
|
||||
def test_explicit_zai(self):
|
||||
assert resolve_provider("zai") == "zai"
|
||||
|
||||
def test_explicit_kimi_coding(self):
|
||||
assert resolve_provider("kimi-coding") == "kimi-coding"
|
||||
|
||||
def test_explicit_minimax(self):
|
||||
assert resolve_provider("minimax") == "minimax"
|
||||
|
||||
def test_explicit_minimax_cn(self):
|
||||
assert resolve_provider("minimax-cn") == "minimax-cn"
|
||||
|
||||
def test_explicit_ai_gateway(self):
|
||||
assert resolve_provider("ai-gateway") == "ai-gateway"
|
||||
|
||||
def test_alias_glm(self):
|
||||
assert resolve_provider("glm") == "zai"
|
||||
|
||||
def test_alias_z_ai(self):
|
||||
assert resolve_provider("z-ai") == "zai"
|
||||
|
||||
def test_alias_zhipu(self):
|
||||
assert resolve_provider("zhipu") == "zai"
|
||||
|
||||
def test_alias_kimi(self):
|
||||
assert resolve_provider("kimi") == "kimi-coding"
|
||||
|
||||
def test_alias_moonshot(self):
|
||||
assert resolve_provider("moonshot") == "kimi-coding"
|
||||
|
||||
def test_alias_minimax_underscore(self):
|
||||
assert resolve_provider("minimax_cn") == "minimax-cn"
|
||||
|
||||
def test_alias_aigateway(self):
|
||||
assert resolve_provider("aigateway") == "ai-gateway"
|
||||
|
||||
def test_alias_vercel(self):
|
||||
assert resolve_provider("vercel") == "ai-gateway"
|
||||
|
||||
def test_explicit_kilocode(self):
|
||||
assert resolve_provider("kilocode") == "kilocode"
|
||||
|
||||
def test_alias_kilo(self):
|
||||
assert resolve_provider("kilo") == "kilocode"
|
||||
|
||||
def test_alias_kilo_code(self):
|
||||
assert resolve_provider("kilo-code") == "kilocode"
|
||||
|
||||
def test_alias_kilo_gateway(self):
|
||||
assert resolve_provider("kilo-gateway") == "kilocode"
|
||||
|
||||
def test_alias_case_insensitive(self):
|
||||
assert resolve_provider("GLM") == "zai"
|
||||
assert resolve_provider("Z-AI") == "zai"
|
||||
assert resolve_provider("Kimi") == "kimi-coding"
|
||||
|
||||
def test_alias_github_copilot(self):
|
||||
assert resolve_provider("github-copilot") == "copilot"
|
||||
|
||||
def test_alias_github_models(self):
|
||||
assert resolve_provider("github-models") == "copilot"
|
||||
|
||||
def test_alias_github_copilot_acp(self):
|
||||
assert resolve_provider("github-copilot-acp") == "copilot-acp"
|
||||
assert resolve_provider("copilot-acp-agent") == "copilot-acp"
|
||||
|
||||
def test_explicit_huggingface(self):
|
||||
assert resolve_provider("huggingface") == "huggingface"
|
||||
|
||||
def test_alias_hf(self):
|
||||
assert resolve_provider("hf") == "huggingface"
|
||||
|
||||
def test_alias_hugging_face(self):
|
||||
assert resolve_provider("hugging-face") == "huggingface"
|
||||
|
||||
def test_alias_huggingface_hub(self):
|
||||
assert resolve_provider("huggingface-hub") == "huggingface"
|
||||
|
||||
def test_unknown_provider_raises(self):
|
||||
with pytest.raises(AuthError):
|
||||
resolve_provider("nonexistent-provider-xyz")
|
||||
|
||||
def test_auto_detects_glm_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "test-glm-key")
|
||||
assert resolve_provider("auto") == "zai"
|
||||
|
||||
def test_auto_detects_zai_key(self, monkeypatch):
|
||||
monkeypatch.setenv("ZAI_API_KEY", "test-zai-key")
|
||||
assert resolve_provider("auto") == "zai"
|
||||
|
||||
def test_auto_detects_z_ai_key(self, monkeypatch):
|
||||
monkeypatch.setenv("Z_AI_API_KEY", "test-z-ai-key")
|
||||
assert resolve_provider("auto") == "zai"
|
||||
|
||||
def test_auto_detects_kimi_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key")
|
||||
assert resolve_provider("auto") == "kimi-coding"
|
||||
|
||||
def test_auto_detects_minimax_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key")
|
||||
assert resolve_provider("auto") == "minimax"
|
||||
|
||||
def test_auto_detects_minimax_cn_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
|
||||
assert resolve_provider("auto") == "minimax-cn"
|
||||
|
||||
def test_auto_detects_ai_gateway_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
|
||||
assert resolve_provider("auto") == "ai-gateway"
|
||||
|
||||
def test_auto_detects_kilocode_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "test-kilo-key")
|
||||
assert resolve_provider("auto") == "kilocode"
|
||||
|
||||
def test_auto_detects_hf_token(self, monkeypatch):
|
||||
monkeypatch.setenv("HF_TOKEN", "hf_test_token")
|
||||
assert resolve_provider("auto") == "huggingface"
|
||||
|
||||
def test_openrouter_takes_priority_over_glm(self, monkeypatch):
|
||||
"""OpenRouter API key should win over GLM in auto-detection."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
|
||||
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token")
|
||||
with pytest.raises(AuthError, match="No inference provider configured"):
|
||||
resolve_provider("auto")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Key Provider Status tests
|
||||
# =============================================================================
|
||||
|
||||
class TestApiKeyProviderStatus:
|
||||
|
||||
def test_unconfigured_provider(self):
|
||||
status = get_api_key_provider_status("zai")
|
||||
assert status["configured"] is False
|
||||
assert status["logged_in"] is False
|
||||
|
||||
def test_configured_provider(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "test-key-123")
|
||||
status = get_api_key_provider_status("zai")
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["key_source"] == "GLM_API_KEY"
|
||||
assert "z.ai" in status["base_url"].lower() or "api.z.ai" in status["base_url"]
|
||||
|
||||
def test_fallback_env_var(self, monkeypatch):
|
||||
"""ZAI_API_KEY should work when GLM_API_KEY is not set."""
|
||||
monkeypatch.setenv("ZAI_API_KEY", "zai-fallback-key")
|
||||
status = get_api_key_provider_status("zai")
|
||||
assert status["configured"] is True
|
||||
assert status["key_source"] == "ZAI_API_KEY"
|
||||
|
||||
def test_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "kimi-key")
|
||||
monkeypatch.setenv("KIMI_BASE_URL", "https://custom.kimi.example/v1")
|
||||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["base_url"] == "https://custom.kimi.example/v1"
|
||||
|
||||
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token")
|
||||
status = get_api_key_provider_status("copilot")
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["key_source"] == "gh auth token"
|
||||
assert status["base_url"] == "https://api.githubcopilot.com"
|
||||
|
||||
def test_get_auth_status_dispatches_to_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
||||
status = get_auth_status("minimax")
|
||||
assert status["configured"] is True
|
||||
assert status["provider"] == "minimax"
|
||||
|
||||
def test_copilot_acp_status_detects_local_cli(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
|
||||
status = get_external_process_provider_status("copilot-acp")
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["command"] == "copilot"
|
||||
assert status["resolved_command"] == "/usr/local/bin/copilot"
|
||||
assert status["args"] == ["--acp", "--stdio", "--debug"]
|
||||
assert status["base_url"] == "acp://copilot"
|
||||
|
||||
def test_get_auth_status_dispatches_to_external_process(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/opt/bin/{command}")
|
||||
|
||||
status = get_auth_status("copilot-acp")
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["provider"] == "copilot-acp"
|
||||
|
||||
def test_non_api_key_provider(self):
|
||||
status = get_api_key_provider_status("nous")
|
||||
assert status["configured"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Credential Resolution tests
|
||||
# =============================================================================
|
||||
|
||||
class TestResolveApiKeyProviderCredentials:
|
||||
|
||||
def test_resolve_zai_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-secret-key")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["provider"] == "zai"
|
||||
assert creds["api_key"] == "glm-secret-key"
|
||||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||
assert creds["source"] == "GLM_API_KEY"
|
||||
|
||||
def test_resolve_copilot_with_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-env-secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
assert creds["provider"] == "copilot"
|
||||
assert creds["api_key"] == "gh-env-secret"
|
||||
assert creds["base_url"] == "https://api.githubcopilot.com"
|
||||
assert creds["source"] == "GITHUB_TOKEN"
|
||||
|
||||
def test_resolve_copilot_with_gh_cli_fallback(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
assert creds["provider"] == "copilot"
|
||||
assert creds["api_key"] == "gho_cli_secret"
|
||||
assert creds["base_url"] == "https://api.githubcopilot.com"
|
||||
assert creds["source"] == "gh auth token"
|
||||
|
||||
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.os.path.isfile",
|
||||
lambda path: path == "/opt/homebrew/bin/gh",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.os.access",
|
||||
lambda path, mode: path == "/opt/homebrew/bin/gh" and mode == os.X_OK,
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = "gh-cli-secret\n"
|
||||
|
||||
def _fake_run(cmd, capture_output, text, timeout):
|
||||
calls.append(cmd)
|
||||
return _Result()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.subprocess.run", _fake_run)
|
||||
|
||||
assert _try_gh_cli_token() == "gh-cli-secret"
|
||||
assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]]
|
||||
|
||||
def test_resolve_copilot_acp_with_local_cli(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio")
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
|
||||
creds = resolve_external_process_provider_credentials("copilot-acp")
|
||||
|
||||
assert creds["provider"] == "copilot-acp"
|
||||
assert creds["api_key"] == "copilot-acp"
|
||||
assert creds["base_url"] == "acp://copilot"
|
||||
assert creds["command"] == "/usr/local/bin/copilot"
|
||||
assert creds["args"] == ["--acp", "--stdio"]
|
||||
assert creds["source"] == "process"
|
||||
|
||||
def test_resolve_kimi_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||
assert creds["provider"] == "kimi-coding"
|
||||
assert creds["api_key"] == "kimi-secret-key"
|
||||
assert creds["base_url"] == "https://api.moonshot.ai/v1"
|
||||
|
||||
def test_resolve_minimax_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("minimax")
|
||||
assert creds["provider"] == "minimax"
|
||||
assert creds["api_key"] == "mm-secret-key"
|
||||
assert creds["base_url"] == "https://api.minimax.io/anthropic"
|
||||
|
||||
def test_resolve_minimax_cn_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("minimax-cn")
|
||||
assert creds["provider"] == "minimax-cn"
|
||||
assert creds["api_key"] == "mmcn-secret-key"
|
||||
assert creds["base_url"] == "https://api.minimaxi.com/anthropic"
|
||||
|
||||
def test_resolve_ai_gateway_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("ai-gateway")
|
||||
assert creds["provider"] == "ai-gateway"
|
||||
assert creds["api_key"] == "gw-secret-key"
|
||||
assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
||||
|
||||
def test_resolve_kilocode_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kilocode")
|
||||
assert creds["provider"] == "kilocode"
|
||||
assert creds["api_key"] == "kilo-secret-key"
|
||||
assert creds["base_url"] == "https://api.kilo.ai/api/gateway"
|
||||
|
||||
def test_resolve_kilocode_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
||||
monkeypatch.setenv("KILOCODE_BASE_URL", "https://custom.kilo.example/v1")
|
||||
creds = resolve_api_key_provider_credentials("kilocode")
|
||||
assert creds["base_url"] == "https://custom.kilo.example/v1"
|
||||
|
||||
def test_resolve_with_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4")
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["base_url"] == "https://custom.glm.example/v4"
|
||||
|
||||
def test_resolve_without_key_returns_empty(self):
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["api_key"] == ""
|
||||
assert creds["source"] == "default"
|
||||
|
||||
def test_resolve_invalid_provider_raises(self):
|
||||
with pytest.raises(AuthError):
|
||||
resolve_api_key_provider_credentials("nous")
|
||||
|
||||
def test_glm_key_priority(self, monkeypatch):
|
||||
"""GLM_API_KEY takes priority over ZAI_API_KEY."""
|
||||
monkeypatch.setenv("GLM_API_KEY", "primary")
|
||||
monkeypatch.setenv("ZAI_API_KEY", "secondary")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["api_key"] == "primary"
|
||||
assert creds["source"] == "GLM_API_KEY"
|
||||
|
||||
def test_zai_key_fallback(self, monkeypatch):
|
||||
"""ZAI_API_KEY used when GLM_API_KEY not set."""
|
||||
monkeypatch.setenv("ZAI_API_KEY", "secondary")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["api_key"] == "secondary"
|
||||
assert creds["source"] == "ZAI_API_KEY"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Runtime Provider Resolution tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRuntimeProviderResolution:
|
||||
|
||||
def test_runtime_zai(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="zai")
|
||||
assert result["provider"] == "zai"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "glm-key"
|
||||
assert "z.ai" in result["base_url"] or "api.z.ai" in result["base_url"]
|
||||
|
||||
def test_runtime_kimi(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "kimi-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="kimi-coding")
|
||||
assert result["provider"] == "kimi-coding"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "kimi-key"
|
||||
|
||||
def test_runtime_minimax(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="minimax")
|
||||
assert result["provider"] == "minimax"
|
||||
assert result["api_key"] == "mm-key"
|
||||
|
||||
def test_runtime_ai_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="ai-gateway")
|
||||
assert result["provider"] == "ai-gateway"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gw-key"
|
||||
assert "ai-gateway.vercel.sh" in result["base_url"]
|
||||
|
||||
def test_runtime_kilocode(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="kilocode")
|
||||
assert result["provider"] == "kilocode"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "kilo-key"
|
||||
assert "kilo.ai" in result["base_url"]
|
||||
|
||||
def test_runtime_auto_detects_api_key_provider(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="auto")
|
||||
assert result["provider"] == "kimi-coding"
|
||||
assert result["api_key"] == "auto-kimi-key"
|
||||
|
||||
def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="copilot")
|
||||
assert result["provider"] == "copilot"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gho_cli_secret"
|
||||
assert result["base_url"] == "https://api.githubcopilot.com"
|
||||
|
||||
def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider._get_model_config",
|
||||
lambda: {"provider": "copilot", "default": "gpt-5.4"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key=None, timeout=5.0: [
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"supported_endpoints": ["/responses"],
|
||||
"capabilities": {"type": "chat"},
|
||||
}
|
||||
],
|
||||
)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
result = resolve_runtime_provider(requested="copilot")
|
||||
|
||||
assert result["provider"] == "copilot"
|
||||
assert result["api_mode"] == "codex_responses"
|
||||
|
||||
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
||||
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
result = resolve_runtime_provider(requested="copilot-acp")
|
||||
|
||||
assert result["provider"] == "copilot-acp"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "copilot-acp"
|
||||
assert result["base_url"] == "acp://copilot"
|
||||
assert result["command"] == "/usr/local/bin/copilot"
|
||||
assert result["args"] == ["--acp", "--stdio", "--debug"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _has_any_provider_configured tests
|
||||
# =============================================================================
|
||||
|
||||
class TestHasAnyProviderConfigured:
|
||||
|
||||
def test_glm_key_counts(self, monkeypatch, tmp_path):
|
||||
from hermes_cli import config as config_module
|
||||
monkeypatch.setenv("GLM_API_KEY", "test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_minimax_key_counts(self, monkeypatch, tmp_path):
|
||||
from hermes_cli import config as config_module
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_gh_cli_token_counts(self, monkeypatch, tmp_path):
|
||||
from hermes_cli import config as config_module
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
||||
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
# Clear all provider env vars so earlier checks don't short-circuit
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Simulate valid Claude Code credentials
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: True,
|
||||
)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is False
|
||||
|
||||
def test_config_provider_counts(self, monkeypatch, tmp_path):
|
||||
"""config.yaml with model.provider set should count as configured."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
config_file.write_text(yaml.dump({
|
||||
"model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"},
|
||||
}))
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
# Clear all provider env vars
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_config_base_url_counts(self, monkeypatch, tmp_path):
|
||||
"""config.yaml with model.base_url set (custom endpoint) should count."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
config_file.write_text(yaml.dump({
|
||||
"model": {"default": "my-model", "base_url": "http://localhost:11434/v1"},
|
||||
}))
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_config_api_key_counts(self, monkeypatch, tmp_path):
|
||||
"""config.yaml with model.api_key set should count."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
config_file.write_text(yaml.dump({
|
||||
"model": {"default": "my-model", "api_key": "sk-test-key"},
|
||||
}))
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_config_dict_no_provider_no_creds_still_false(self, monkeypatch, tmp_path):
|
||||
"""config.yaml model dict with empty default and no creds stays false."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
config_file.write_text(yaml.dump({
|
||||
"model": {"default": ""},
|
||||
}))
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is False
|
||||
|
||||
def test_claude_code_creds_counted_when_hermes_configured(self, monkeypatch, tmp_path):
|
||||
"""Claude Code credentials should count when Hermes has been explicitly configured."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
# Write a config with a non-default model to simulate explicit configuration
|
||||
config_file = hermes_home / "config.yaml"
|
||||
config_file.write_text(yaml.dump({"model": {"default": "my-local-model"}}))
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
# Clear all provider env vars
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Simulate valid Claude Code credentials
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: True,
|
||||
)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kimi Code auto-detection tests
|
||||
# =============================================================================
|
||||
|
||||
MOONSHOT_DEFAULT_URL = "https://api.moonshot.ai/v1"
|
||||
|
||||
|
||||
class TestResolveKimiBaseUrl:
|
||||
"""Test _resolve_kimi_base_url() helper for key-prefix auto-detection."""
|
||||
|
||||
def test_sk_kimi_prefix_routes_to_kimi_code(self):
|
||||
url = _resolve_kimi_base_url("sk-kimi-abc123", MOONSHOT_DEFAULT_URL, "")
|
||||
assert url == KIMI_CODE_BASE_URL
|
||||
|
||||
def test_legacy_key_uses_default(self):
|
||||
url = _resolve_kimi_base_url("sk-abc123", MOONSHOT_DEFAULT_URL, "")
|
||||
assert url == MOONSHOT_DEFAULT_URL
|
||||
|
||||
def test_empty_key_uses_default(self):
|
||||
url = _resolve_kimi_base_url("", MOONSHOT_DEFAULT_URL, "")
|
||||
assert url == MOONSHOT_DEFAULT_URL
|
||||
|
||||
def test_env_override_wins_over_sk_kimi(self):
|
||||
"""KIMI_BASE_URL env var should always take priority."""
|
||||
custom = "https://custom.example.com/v1"
|
||||
url = _resolve_kimi_base_url("sk-kimi-abc123", MOONSHOT_DEFAULT_URL, custom)
|
||||
assert url == custom
|
||||
|
||||
def test_env_override_wins_over_legacy(self):
|
||||
custom = "https://custom.example.com/v1"
|
||||
url = _resolve_kimi_base_url("sk-abc123", MOONSHOT_DEFAULT_URL, custom)
|
||||
assert url == custom
|
||||
|
||||
|
||||
class TestKimiCodeStatusAutoDetect:
|
||||
"""Test that get_api_key_provider_status auto-detects sk-kimi- keys."""
|
||||
|
||||
def test_sk_kimi_key_gets_kimi_code_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key-123")
|
||||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["configured"] is True
|
||||
assert status["base_url"] == KIMI_CODE_BASE_URL
|
||||
|
||||
def test_legacy_key_gets_moonshot_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-legacy-test-key")
|
||||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["configured"] is True
|
||||
assert status["base_url"] == MOONSHOT_DEFAULT_URL
|
||||
|
||||
def test_env_override_wins(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key")
|
||||
monkeypatch.setenv("KIMI_BASE_URL", "https://override.example/v1")
|
||||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["base_url"] == "https://override.example/v1"
|
||||
|
||||
|
||||
class TestKimiCodeCredentialAutoDetect:
|
||||
"""Test that resolve_api_key_provider_credentials auto-detects sk-kimi- keys."""
|
||||
|
||||
def test_sk_kimi_key_gets_kimi_code_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||
assert creds["api_key"] == "sk-kimi-secret-key"
|
||||
assert creds["base_url"] == KIMI_CODE_BASE_URL
|
||||
|
||||
def test_legacy_key_gets_moonshot_url(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-legacy-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||
assert creds["api_key"] == "sk-legacy-secret-key"
|
||||
assert creds["base_url"] == MOONSHOT_DEFAULT_URL
|
||||
|
||||
def test_env_override_wins(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-secret-key")
|
||||
monkeypatch.setenv("KIMI_BASE_URL", "https://override.example/v1")
|
||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||
assert creds["base_url"] == "https://override.example/v1"
|
||||
|
||||
def test_non_kimi_providers_unaffected(self, monkeypatch):
|
||||
"""Ensure the auto-detect logic doesn't leak to other providers."""
|
||||
monkeypatch.setenv("GLM_API_KEY", "sk-kim...isnt")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||
|
||||
|
||||
class TestZaiEndpointAutoDetect:
|
||||
"""Test that resolve_api_key_provider_credentials auto-detects Z.AI endpoints."""
|
||||
|
||||
def test_probe_success_returns_detected_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-coding-key")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.detect_zai_endpoint",
|
||||
lambda *a, **kw: {
|
||||
"id": "coding-global",
|
||||
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
||||
"model": "glm-4.7",
|
||||
"label": "Global (Coding Plan)",
|
||||
},
|
||||
)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["base_url"] == "https://api.z.ai/api/coding/paas/v4"
|
||||
|
||||
def test_probe_failure_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||
|
||||
def test_env_override_skips_probe(self, monkeypatch):
|
||||
"""GLM_BASE_URL should always win without probing."""
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
monkeypatch.setenv("GLM_BASE_URL", "https://custom.example/v4")
|
||||
probe_called = False
|
||||
|
||||
def _never_called(*a, **kw):
|
||||
nonlocal probe_called
|
||||
probe_called = True
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", _never_called)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["base_url"] == "https://custom.example/v4"
|
||||
assert not probe_called
|
||||
|
||||
def test_no_key_skips_probe(self, monkeypatch):
|
||||
"""Without an API key, no probe should occur."""
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None)
|
||||
creds = resolve_api_key_provider_credentials("zai")
|
||||
assert creds["api_key"] == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kimi / Moonshot model list isolation tests
|
||||
# =============================================================================
|
||||
|
||||
class TestKimiMoonshotModelListIsolation:
|
||||
"""Moonshot (legacy) users must not see Coding Plan-only models."""
|
||||
|
||||
def test_moonshot_list_excludes_coding_plan_only_models(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
||||
coding_plan_only = {"kimi-for-coding", "kimi-k2-thinking-turbo"}
|
||||
leaked = set(moonshot_models) & coding_plan_only
|
||||
assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}"
|
||||
|
||||
def test_moonshot_list_contains_shared_models(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
||||
assert "kimi-k2.5" in moonshot_models
|
||||
assert "kimi-k2-thinking" in moonshot_models
|
||||
|
||||
def test_coding_plan_list_contains_plan_specific_models(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
coding_models = _PROVIDER_MODELS["kimi-coding"]
|
||||
assert "kimi-for-coding" in coding_models
|
||||
assert "kimi-k2-thinking-turbo" in coding_models
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hugging Face provider model list tests
|
||||
# =============================================================================
|
||||
|
||||
class TestHuggingFaceModels:
|
||||
"""Verify Hugging Face model lists are consistent across all locations."""
|
||||
|
||||
def test_main_provider_models_has_huggingface(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
assert "huggingface" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["huggingface"]
|
||||
assert len(models) >= 6, "Expected at least 6 curated HF models"
|
||||
|
||||
def test_models_py_has_huggingface(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "huggingface" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["huggingface"]
|
||||
assert len(models) >= 6
|
||||
|
||||
def test_model_lists_match(self):
|
||||
"""Model lists in main.py and models.py should be identical."""
|
||||
from hermes_cli.main import _PROVIDER_MODELS as main_models
|
||||
from hermes_cli.models import _PROVIDER_MODELS as models_models
|
||||
assert main_models["huggingface"] == models_models["huggingface"]
|
||||
|
||||
def test_model_metadata_has_context_lengths(self):
|
||||
"""Every HF model should have a context length entry."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
hf_models = _PROVIDER_MODELS["huggingface"]
|
||||
for model in hf_models:
|
||||
assert model in DEFAULT_CONTEXT_LENGTHS, (
|
||||
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
)
|
||||
|
||||
def test_models_use_org_name_format(self):
|
||||
"""HF models should use org/name format (e.g. Qwen/Qwen3-235B)."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
for model in _PROVIDER_MODELS["huggingface"]:
|
||||
assert "/" in model, f"HF model {model!r} missing org/ prefix"
|
||||
|
||||
def test_provider_aliases_in_models_py(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("hf") == "huggingface"
|
||||
assert _PROVIDER_ALIASES.get("hugging-face") == "huggingface"
|
||||
|
||||
def test_provider_label(self):
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert "huggingface" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["huggingface"] == "Hugging Face"
|
||||
159
tests/hermes_cli/test_atomic_json_write.py
Normal file
159
tests/hermes_cli/test_atomic_json_write.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from utils import atomic_json_write
|
||||
|
||||
|
||||
class TestAtomicJsonWrite:
|
||||
"""Core atomic write behavior."""
|
||||
|
||||
def test_writes_valid_json(self, tmp_path):
|
||||
target = tmp_path / "data.json"
|
||||
data = {"key": "value", "nested": {"a": 1}}
|
||||
atomic_json_write(target, data)
|
||||
|
||||
result = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert result == data
|
||||
|
||||
def test_creates_parent_directories(self, tmp_path):
|
||||
target = tmp_path / "deep" / "nested" / "dir" / "data.json"
|
||||
atomic_json_write(target, {"ok": True})
|
||||
|
||||
assert target.exists()
|
||||
assert json.loads(target.read_text())["ok"] is True
|
||||
|
||||
def test_overwrites_existing_file(self, tmp_path):
|
||||
target = tmp_path / "data.json"
|
||||
target.write_text('{"old": true}')
|
||||
|
||||
atomic_json_write(target, {"new": True})
|
||||
result = json.loads(target.read_text())
|
||||
assert result == {"new": True}
|
||||
|
||||
def test_preserves_original_on_serialization_error(self, tmp_path):
|
||||
target = tmp_path / "data.json"
|
||||
original = {"preserved": True}
|
||||
target.write_text(json.dumps(original))
|
||||
|
||||
# Try to write non-serializable data — should fail
|
||||
with pytest.raises(TypeError):
|
||||
atomic_json_write(target, {"bad": object()})
|
||||
|
||||
# Original file should be untouched
|
||||
result = json.loads(target.read_text())
|
||||
assert result == original
|
||||
|
||||
def test_no_leftover_temp_files_on_success(self, tmp_path):
|
||||
target = tmp_path / "data.json"
|
||||
atomic_json_write(target, [1, 2, 3])
|
||||
|
||||
# No .tmp files should be left behind
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
assert target.exists()
|
||||
|
||||
def test_no_leftover_temp_files_on_failure(self, tmp_path):
|
||||
target = tmp_path / "data.json"
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
atomic_json_write(target, {"bad": object()})
|
||||
|
||||
# No temp files should be left behind
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
|
||||
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
|
||||
class SimulatedAbort(BaseException):
|
||||
pass
|
||||
|
||||
target = tmp_path / "data.json"
|
||||
original = {"preserved": True}
|
||||
target.write_text(json.dumps(original), encoding="utf-8")
|
||||
|
||||
with patch("utils.json.dump", side_effect=SimulatedAbort):
|
||||
with pytest.raises(SimulatedAbort):
|
||||
atomic_json_write(target, {"new": True})
|
||||
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == original
|
||||
|
||||
def test_accepts_string_path(self, tmp_path):
|
||||
target = str(tmp_path / "string_path.json")
|
||||
atomic_json_write(target, {"string": True})
|
||||
|
||||
result = json.loads(Path(target).read_text())
|
||||
assert result == {"string": True}
|
||||
|
||||
def test_writes_list_data(self, tmp_path):
|
||||
target = tmp_path / "list.json"
|
||||
data = [1, "two", {"three": 3}]
|
||||
atomic_json_write(target, data)
|
||||
|
||||
result = json.loads(target.read_text())
|
||||
assert result == data
|
||||
|
||||
def test_empty_list(self, tmp_path):
|
||||
target = tmp_path / "empty.json"
|
||||
atomic_json_write(target, [])
|
||||
|
||||
result = json.loads(target.read_text())
|
||||
assert result == []
|
||||
|
||||
def test_custom_indent(self, tmp_path):
|
||||
target = tmp_path / "custom.json"
|
||||
atomic_json_write(target, {"a": 1}, indent=4)
|
||||
|
||||
text = target.read_text()
|
||||
assert ' "a"' in text # 4-space indent
|
||||
|
||||
def test_accepts_json_dump_default_hook(self, tmp_path):
|
||||
class CustomValue:
|
||||
def __str__(self):
|
||||
return "custom-value"
|
||||
|
||||
target = tmp_path / "custom_default.json"
|
||||
atomic_json_write(target, {"value": CustomValue()}, default=str)
|
||||
|
||||
result = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert result == {"value": "custom-value"}
|
||||
|
||||
def test_unicode_content(self, tmp_path):
|
||||
target = tmp_path / "unicode.json"
|
||||
data = {"emoji": "🎉", "japanese": "日本語"}
|
||||
atomic_json_write(target, data)
|
||||
|
||||
result = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert result["emoji"] == "🎉"
|
||||
assert result["japanese"] == "日本語"
|
||||
|
||||
def test_concurrent_writes_dont_corrupt(self, tmp_path):
|
||||
"""Multiple rapid writes should each produce valid JSON."""
|
||||
import threading
|
||||
|
||||
target = tmp_path / "concurrent.json"
|
||||
errors = []
|
||||
|
||||
def writer(n):
|
||||
try:
|
||||
atomic_json_write(target, {"writer": n, "data": list(range(100))})
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
# File should contain valid JSON from one of the writers
|
||||
result = json.loads(target.read_text())
|
||||
assert "writer" in result
|
||||
assert len(result["data"]) == 100
|
||||
44
tests/hermes_cli/test_atomic_yaml_write.py
Normal file
44
tests/hermes_cli/test_atomic_yaml_write.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
|
||||
class TestAtomicYamlWrite:
|
||||
def test_writes_valid_yaml(self, tmp_path):
|
||||
target = tmp_path / "data.yaml"
|
||||
data = {"key": "value", "nested": {"a": 1}}
|
||||
|
||||
atomic_yaml_write(target, data)
|
||||
|
||||
assert yaml.safe_load(target.read_text(encoding="utf-8")) == data
|
||||
|
||||
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
|
||||
class SimulatedAbort(BaseException):
|
||||
pass
|
||||
|
||||
target = tmp_path / "data.yaml"
|
||||
original = {"preserved": True}
|
||||
target.write_text(yaml.safe_dump(original), encoding="utf-8")
|
||||
|
||||
with patch("utils.yaml.dump", side_effect=SimulatedAbort):
|
||||
with pytest.raises(SimulatedAbort):
|
||||
atomic_yaml_write(target, {"new": True})
|
||||
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
assert yaml.safe_load(target.read_text(encoding="utf-8")) == original
|
||||
|
||||
def test_appends_extra_content(self, tmp_path):
|
||||
target = tmp_path / "data.yaml"
|
||||
|
||||
atomic_yaml_write(target, {"key": "value"}, extra_content="\n# comment\n")
|
||||
|
||||
text = target.read_text(encoding="utf-8")
|
||||
assert "key: value" in text
|
||||
assert "# comment" in text
|
||||
192
tests/hermes_cli/test_auth_codex_provider.py
Normal file
192
tests/hermes_cli/test_auth_codex_provider.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests for Codex auth — tokens stored in Hermes auth store (~/.hermes/auth.json)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_read_codex_tokens,
|
||||
_save_codex_tokens,
|
||||
_import_codex_cli_tokens,
|
||||
get_codex_auth_status,
|
||||
get_provider_auth_state,
|
||||
resolve_codex_runtime_credentials,
|
||||
resolve_provider,
|
||||
)
|
||||
|
||||
|
||||
def _setup_hermes_auth(hermes_home: Path, *, access_token: str = "access", refresh_token: str = "refresh"):
|
||||
"""Write Codex tokens into the Hermes auth store."""
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": "openai-codex",
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
"last_refresh": "2026-02-26T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}
|
||||
auth_file = hermes_home / "auth.json"
|
||||
auth_file.write_text(json.dumps(auth_store, indent=2))
|
||||
return auth_file
|
||||
|
||||
|
||||
def _jwt_with_exp(exp_epoch: int) -> str:
|
||||
payload = {"exp": exp_epoch}
|
||||
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).rstrip(b"=").decode("utf-8")
|
||||
return f"h.{encoded}.s"
|
||||
|
||||
|
||||
def test_read_codex_tokens_success(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
data = _read_codex_tokens()
|
||||
assert data["tokens"]["access_token"] == "access"
|
||||
assert data["tokens"]["refresh_token"] == "refresh"
|
||||
|
||||
|
||||
def test_read_codex_tokens_missing(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
# Empty auth store
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_codex_tokens()
|
||||
assert exc.value.code == "codex_auth_missing"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
assert exc.value.code == "codex_auth_missing_access_token"
|
||||
assert exc.value.relogin_required is True
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
expiring_token = _jwt_with_exp(int(time.time()) - 10)
|
||||
_setup_hermes_auth(hermes_home, access_token=expiring_token, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
def _fake_refresh(tokens, timeout_seconds):
|
||||
called["count"] += 1
|
||||
return {"access_token": "access-new", "refresh_token": "refresh-new"}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
|
||||
assert called["count"] == 1
|
||||
assert resolved["api_key"] == "access-new"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_force_refresh(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="access-current", refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
called = {"count": 0}
|
||||
|
||||
def _fake_refresh(tokens, timeout_seconds):
|
||||
called["count"] += 1
|
||||
return {"access_token": "access-forced", "refresh_token": "refresh-new"}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
|
||||
|
||||
resolved = resolve_codex_runtime_credentials(force_refresh=True, refresh_if_expiring=False)
|
||||
|
||||
assert called["count"] == 1
|
||||
assert resolved["api_key"] == "access-forced"
|
||||
|
||||
|
||||
def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
assert resolve_provider("openai-codex") == "openai-codex"
|
||||
|
||||
|
||||
def test_save_codex_tokens_roundtrip(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens({"access_token": "at123", "refresh_token": "rt456"})
|
||||
data = _read_codex_tokens()
|
||||
|
||||
assert data["tokens"]["access_token"] == "at123"
|
||||
assert data["tokens"]["refresh_token"] == "rt456"
|
||||
|
||||
|
||||
def test_import_codex_cli_tokens(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {"access_token": "cli-at", "refresh_token": "cli-rt"},
|
||||
}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
tokens = _import_codex_cli_tokens()
|
||||
assert tokens is not None
|
||||
assert tokens["access_token"] == "cli-at"
|
||||
assert tokens["refresh_token"] == "cli-rt"
|
||||
|
||||
|
||||
def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
assert _import_codex_cli_tokens() is None
|
||||
|
||||
|
||||
def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
|
||||
"""Verify Hermes never writes to ~/.codex/auth.json."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
_save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"})
|
||||
|
||||
# ~/.codex/auth.json should NOT exist
|
||||
assert not (codex_home / "auth.json").exists()
|
||||
|
||||
# Hermes auth store should have the tokens
|
||||
data = _read_codex_tokens()
|
||||
assert data["tokens"]["access_token"] == "hermes-at"
|
||||
|
||||
|
||||
def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
creds = resolve_codex_runtime_credentials()
|
||||
assert creds["source"] == "hermes-auth-store"
|
||||
assert creds["provider"] == "openai-codex"
|
||||
assert creds["base_url"] == DEFAULT_CODEX_BASE_URL
|
||||
659
tests/hermes_cli/test_auth_commands.py
Normal file
659
tests/hermes_cli/test_auth_commands.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""Tests for auth subcommands backed by the credential pool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_auth_store(tmp_path, payload: dict) -> None:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def _jwt_with_email(email: str) -> str:
|
||||
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"email": email}).encode()
|
||||
).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.signature"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_provider_env(monkeypatch):
|
||||
for key in (
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_auth_add_api_key_persists_manual_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
auth_type = "api-key"
|
||||
api_key = "sk-or-manual"
|
||||
label = "personal"
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openrouter"]
|
||||
entry = next(item for item in entries if item["source"] == "manual")
|
||||
assert entry["label"] == "personal"
|
||||
assert entry["auth_type"] == "api_key"
|
||||
assert entry["source"] == "manual"
|
||||
assert entry["access_token"] == "sk-or-manual"
|
||||
|
||||
|
||||
def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
token = _jwt_with_email("claude@example.com")
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.run_hermes_oauth_login_pure",
|
||||
lambda: {
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at_ms": 1711234567000,
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "anthropic"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["anthropic"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:hermes_pkce")
|
||||
assert entry["label"] == "claude@example.com"
|
||||
assert entry["source"] == "manual:hermes_pkce"
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["expires_at_ms"] == 1711234567000
|
||||
|
||||
|
||||
def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
token = _jwt_with_email("nous@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._nous_device_code_login",
|
||||
lambda **kwargs: {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"scope": "inference:mint_agent_key",
|
||||
"token_type": "Bearer",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"obtained_at": "2026-03-23T10:00:00+00:00",
|
||||
"expires_at": "2026-03-23T11:00:00+00:00",
|
||||
"expires_in": 3600,
|
||||
"agent_key": "ak-test",
|
||||
"agent_key_id": "ak-id",
|
||||
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
|
||||
"agent_key_expires_in": 1800,
|
||||
"agent_key_reused": False,
|
||||
"agent_key_obtained_at": "2026-03-23T10:00:10+00:00",
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "nous"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
portal_url = None
|
||||
inference_url = None
|
||||
client_id = None
|
||||
scope = None
|
||||
no_browser = False
|
||||
timeout = None
|
||||
insecure = False
|
||||
ca_bundle = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["nous"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
assert entry["label"] == "nous@example.com"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["agent_key"] == "ak-test"
|
||||
assert entry["portal_base_url"] == "https://portal.example.com"
|
||||
|
||||
|
||||
def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
token = _jwt_with_email("codex@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._codex_device_code_login",
|
||||
lambda: {
|
||||
"tokens": {
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": "2026-03-23T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
assert entry["label"] == "codex@example.com"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def test_auth_remove_reindexes_priorities(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
# Prevent pool auto-seeding from host env vars and file-backed sources
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-ant-api-primary",
|
||||
},
|
||||
{
|
||||
"id": "cred-2",
|
||||
"label": "secondary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 1,
|
||||
"source": "manual",
|
||||
"access_token": "sk-ant-api-secondary",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "anthropic"
|
||||
target = "1"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["anthropic"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["label"] == "secondary"
|
||||
assert entries[0]["priority"] == 0
|
||||
|
||||
|
||||
def test_auth_remove_accepts_label_target(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "work-account",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "tok-1",
|
||||
},
|
||||
{
|
||||
"id": "cred-2",
|
||||
"label": "personal-account",
|
||||
"auth_type": "oauth",
|
||||
"priority": 1,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "tok-2",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
target = "personal-account"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["label"] == "work-account"
|
||||
|
||||
|
||||
def test_auth_remove_prefers_exact_numeric_label_over_index(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "cred-a",
|
||||
"label": "first",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "tok-a",
|
||||
},
|
||||
{
|
||||
"id": "cred-b",
|
||||
"label": "2",
|
||||
"auth_type": "oauth",
|
||||
"priority": 1,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "tok-b",
|
||||
},
|
||||
{
|
||||
"id": "cred-c",
|
||||
"label": "third",
|
||||
"auth_type": "oauth",
|
||||
"priority": 2,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "tok-c",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
target = "2"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
labels = [entry["label"] for entry in payload["credential_pool"]["openai-codex"]]
|
||||
assert labels == ["first", "third"]
|
||||
|
||||
|
||||
def test_auth_reset_clears_provider_statuses(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-ant-api-primary",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": 1711230000.0,
|
||||
"last_error_code": 402,
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_reset_command
|
||||
|
||||
class _Args:
|
||||
provider = "anthropic"
|
||||
|
||||
auth_reset_command(_Args())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Reset status" in out
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entry = payload["credential_pool"]["anthropic"][0]
|
||||
assert entry["last_status"] is None
|
||||
assert entry["last_status_at"] is None
|
||||
assert entry["last_error_code"] is None
|
||||
|
||||
|
||||
def test_clear_provider_auth_removes_provider_pool_entries(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"active_provider": "anthropic",
|
||||
"providers": {
|
||||
"anthropic": {"access_token": "legacy-token"},
|
||||
},
|
||||
"credential_pool": {
|
||||
"anthropic": [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:hermes_pkce",
|
||||
"access_token": "pool-token",
|
||||
}
|
||||
],
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "cred-2",
|
||||
"label": "other-provider",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-or-test",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
|
||||
assert clear_provider_auth("anthropic") is True
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
assert payload["active_provider"] is None
|
||||
assert "anthropic" not in payload.get("providers", {})
|
||||
assert "anthropic" not in payload.get("credential_pool", {})
|
||||
assert "openrouter" in payload.get("credential_pool", {})
|
||||
|
||||
|
||||
def test_auth_list_does_not_call_mutating_select(monkeypatch, capsys):
|
||||
from hermes_cli.auth_commands import auth_list_command
|
||||
|
||||
class _Entry:
|
||||
id = "cred-1"
|
||||
label = "primary"
|
||||
auth_type="***"
|
||||
source = "manual"
|
||||
last_status = None
|
||||
last_error_code = None
|
||||
last_status_at = None
|
||||
|
||||
class _Pool:
|
||||
def entries(self):
|
||||
return [_Entry()]
|
||||
|
||||
def peek(self):
|
||||
return _Entry()
|
||||
|
||||
def select(self):
|
||||
raise AssertionError("auth_list_command should not call select()")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth_commands.load_pool",
|
||||
lambda provider: _Pool() if provider == "openrouter" else type("_EmptyPool", (), {"entries": lambda self: []})(),
|
||||
)
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
|
||||
auth_list_command(_Args())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "openrouter (1 credentials):" in out
|
||||
assert "primary" in out
|
||||
|
||||
|
||||
def test_auth_list_shows_exhausted_cooldown(monkeypatch, capsys):
|
||||
from hermes_cli.auth_commands import auth_list_command
|
||||
|
||||
class _Entry:
|
||||
id = "cred-1"
|
||||
label = "primary"
|
||||
auth_type = "api_key"
|
||||
source = "manual"
|
||||
last_status = "exhausted"
|
||||
last_error_code = 429
|
||||
last_status_at = 1000.0
|
||||
|
||||
class _Pool:
|
||||
def entries(self):
|
||||
return [_Entry()]
|
||||
|
||||
def peek(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth_commands.load_pool", lambda provider: _Pool())
|
||||
monkeypatch.setattr("hermes_cli.auth_commands.time.time", lambda: 1030.0)
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
|
||||
auth_list_command(_Args())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "exhausted (429)" in out
|
||||
assert "59m 30s left" in out
|
||||
|
||||
|
||||
def test_auth_list_prefers_explicit_reset_time(monkeypatch, capsys):
|
||||
from hermes_cli.auth_commands import auth_list_command
|
||||
|
||||
class _Entry:
|
||||
id = "cred-1"
|
||||
label = "weekly"
|
||||
auth_type = "oauth"
|
||||
source = "manual:device_code"
|
||||
last_status = "exhausted"
|
||||
last_error_code = 429
|
||||
last_error_reason = "device_code_exhausted"
|
||||
last_error_message = "Weekly credits exhausted."
|
||||
last_error_reset_at = "2026-04-12T10:30:00Z"
|
||||
last_status_at = 1000.0
|
||||
|
||||
class _Pool:
|
||||
def entries(self):
|
||||
return [_Entry()]
|
||||
|
||||
def peek(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth_commands.load_pool", lambda provider: _Pool())
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth_commands.time.time",
|
||||
lambda: datetime(2026, 4, 5, 10, 30, tzinfo=timezone.utc).timestamp(),
|
||||
)
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
|
||||
auth_list_command(_Args())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "device_code_exhausted" in out
|
||||
assert "7d 0h left" in out
|
||||
|
||||
|
||||
def test_auth_remove_env_seeded_clears_env_var(tmp_path, monkeypatch):
|
||||
"""Removing an env-seeded credential should also clear the env var from .env
|
||||
so the entry doesn't get re-seeded on the next load_pool() call."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Write a .env with an OpenRouter key
|
||||
env_path = hermes_home / ".env"
|
||||
env_path.write_text("OPENROUTER_API_KEY=sk-or-test-key-12345\nOTHER_KEY=keep-me\n")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-key-12345")
|
||||
|
||||
# Seed the pool with the env entry
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "env-1",
|
||||
"label": "OPENROUTER_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "env:OPENROUTER_API_KEY",
|
||||
"access_token": "sk-or-test-key-12345",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
target = "1"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
# Env var should be cleared from os.environ
|
||||
import os
|
||||
assert os.environ.get("OPENROUTER_API_KEY") is None
|
||||
|
||||
# Env var should be removed from .env file
|
||||
env_content = env_path.read_text()
|
||||
assert "OPENROUTER_API_KEY" not in env_content
|
||||
# Other keys should still be there
|
||||
assert "OTHER_KEY=keep-me" in env_content
|
||||
|
||||
|
||||
def test_auth_remove_env_seeded_does_not_resurrect(tmp_path, monkeypatch):
|
||||
"""After removing an env-seeded credential, load_pool should NOT re-create it."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Write .env with an OpenRouter key
|
||||
env_path = hermes_home / ".env"
|
||||
env_path.write_text("OPENROUTER_API_KEY=sk-or-test-key-12345\n")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-key-12345")
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "env-1",
|
||||
"label": "OPENROUTER_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "env:OPENROUTER_API_KEY",
|
||||
"access_token": "sk-or-test-key-12345",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
target = "1"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
# Now reload the pool — the entry should NOT come back
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("openrouter")
|
||||
assert not pool.has_credentials()
|
||||
|
||||
|
||||
def test_auth_remove_manual_entry_does_not_touch_env(tmp_path, monkeypatch):
|
||||
"""Removing a manually-added credential should NOT touch .env."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
env_path = hermes_home / ".env"
|
||||
env_path.write_text("SOME_KEY=some-value\n")
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "manual-1",
|
||||
"label": "my-key",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-or-manual-key",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
class _Args:
|
||||
provider = "openrouter"
|
||||
target = "1"
|
||||
|
||||
auth_remove_command(_Args())
|
||||
|
||||
# .env should be untouched
|
||||
assert env_path.read_text() == "SOME_KEY=some-value\n"
|
||||
156
tests/hermes_cli/test_auth_nous_provider.py
Normal file
156
tests/hermes_cli/test_auth_nous_provider.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Regression tests for Nous OAuth refresh + agent-key mint interactions."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import AuthError, get_provider_auth_state, resolve_nous_runtime_credentials
|
||||
|
||||
|
||||
def _setup_nous_auth(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
access_token: str = "access-old",
|
||||
refresh_token: str = "refresh-old",
|
||||
) -> None:
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": "nous",
|
||||
"providers": {
|
||||
"nous": {
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"token_type": "Bearer",
|
||||
"scope": "inference:mint_agent_key",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"obtained_at": "2026-02-01T00:00:00+00:00",
|
||||
"expires_in": 0,
|
||||
"expires_at": "2026-02-01T00:00:00+00:00",
|
||||
"agent_key": None,
|
||||
"agent_key_id": None,
|
||||
"agent_key_expires_at": None,
|
||||
"agent_key_expires_in": None,
|
||||
"agent_key_reused": None,
|
||||
"agent_key_obtained_at": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store, indent=2))
|
||||
|
||||
|
||||
def _mint_payload(api_key: str = "agent-key") -> dict:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"key_id": "key-id-1",
|
||||
"expires_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_in": 1800,
|
||||
"reused": False,
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_token_persisted_when_mint_returns_insufficient_credits(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
refresh_calls = []
|
||||
mint_calls = {"count": 0}
|
||||
|
||||
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
|
||||
refresh_calls.append(refresh_token)
|
||||
idx = len(refresh_calls)
|
||||
return {
|
||||
"access_token": f"access-{idx}",
|
||||
"refresh_token": f"refresh-{idx}",
|
||||
"expires_in": 0,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
|
||||
mint_calls["count"] += 1
|
||||
if mint_calls["count"] == 1:
|
||||
raise AuthError("credits exhausted", provider="nous", code="insufficient_credits")
|
||||
return _mint_payload(api_key="agent-key-2")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token)
|
||||
monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key)
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
|
||||
assert exc.value.code == "insufficient_credits"
|
||||
|
||||
state_after_failure = get_provider_auth_state("nous")
|
||||
assert state_after_failure is not None
|
||||
assert state_after_failure["refresh_token"] == "refresh-1"
|
||||
assert state_after_failure["access_token"] == "access-1"
|
||||
|
||||
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
|
||||
assert creds["api_key"] == "agent-key-2"
|
||||
assert refresh_calls == ["refresh-old", "refresh-1"]
|
||||
|
||||
|
||||
def test_refresh_token_persisted_when_mint_times_out(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
|
||||
return {
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"expires_in": 0,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
|
||||
raise httpx.ReadTimeout("mint timeout")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token)
|
||||
monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key)
|
||||
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
|
||||
|
||||
state_after_failure = get_provider_auth_state("nous")
|
||||
assert state_after_failure is not None
|
||||
assert state_after_failure["refresh_token"] == "refresh-1"
|
||||
assert state_after_failure["access_token"] == "access-1"
|
||||
|
||||
|
||||
def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
refresh_calls = []
|
||||
mint_calls = {"count": 0}
|
||||
|
||||
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
|
||||
refresh_calls.append(refresh_token)
|
||||
idx = len(refresh_calls)
|
||||
return {
|
||||
"access_token": f"access-{idx}",
|
||||
"refresh_token": f"refresh-{idx}",
|
||||
"expires_in": 0,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
|
||||
mint_calls["count"] += 1
|
||||
if mint_calls["count"] == 1:
|
||||
raise AuthError("stale access token", provider="nous", code="invalid_token")
|
||||
return _mint_payload(api_key="agent-key")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token)
|
||||
monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key)
|
||||
|
||||
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
|
||||
assert creds["api_key"] == "agent-key"
|
||||
assert refresh_calls == ["refresh-old", "refresh-1"]
|
||||
|
||||
264
tests/hermes_cli/test_codex_models.py
Normal file
264
tests/hermes_cli/test_codex_models.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, get_codex_model_ids
|
||||
|
||||
|
||||
def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
(codex_home / "config.toml").write_text('model = "gpt-5.2-codex"\n')
|
||||
(codex_home / "models_cache.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"models": [
|
||||
{"slug": "gpt-5.3-codex", "priority": 20, "supported_in_api": True},
|
||||
{"slug": "gpt-5.1-codex", "priority": 5, "supported_in_api": True},
|
||||
{"slug": "gpt-5.4", "priority": 1, "supported_in_api": True},
|
||||
{"slug": "gpt-5-hidden-codex", "priority": 2, "visibility": "hidden"},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
models = get_codex_model_ids()
|
||||
|
||||
assert models[0] == "gpt-5.2-codex"
|
||||
assert "gpt-5.1-codex" in models
|
||||
assert "gpt-5.3-codex" in models
|
||||
# Non-codex-suffixed models are included when the cache says they're available
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.4-mini" in models
|
||||
assert "gpt-5-hidden-codex" not in models
|
||||
|
||||
|
||||
def test_setup_wizard_codex_import_resolves():
|
||||
"""Regression test for #712: setup.py must import the correct function name."""
|
||||
# This mirrors the exact import used in hermes_cli/setup.py line 873.
|
||||
# A prior bug had 'get_codex_models' (wrong) instead of 'get_codex_model_ids'.
|
||||
from hermes_cli.codex_models import get_codex_model_ids as setup_import
|
||||
assert callable(setup_import)
|
||||
|
||||
|
||||
def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-home"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
models = get_codex_model_ids()
|
||||
|
||||
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.3-codex-spark" in models
|
||||
|
||||
|
||||
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models._fetch_models_from_api",
|
||||
lambda access_token: ["gpt-5.2-codex"],
|
||||
)
|
||||
|
||||
models = get_codex_model_ids(access_token="codex-access-token")
|
||||
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark"]
|
||||
|
||||
|
||||
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
from hermes_cli.main import _model_flow_openai_codex
|
||||
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_codex_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
||||
lambda *args, **kwargs: {"api_key": "codex-access-token"},
|
||||
)
|
||||
|
||||
def _fake_get_codex_model_ids(access_token=None):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
def _fake_prompt_model_selection(model_ids, current_model=""):
|
||||
captured["model_ids"] = list(model_ids)
|
||||
captured["current_model"] = current_model
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
_fake_get_codex_model_ids,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
_fake_prompt_model_selection,
|
||||
)
|
||||
|
||||
_model_flow_openai_codex({}, current_model="openai/gpt-5.4")
|
||||
|
||||
assert captured["access_token"] == "codex-access-token"
|
||||
assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"]
|
||||
assert captured["current_model"] == "openai/gpt-5.4"
|
||||
|
||||
|
||||
# ── Tests for _normalize_model_for_provider ──────────────────────────
|
||||
|
||||
|
||||
def _make_cli(model="anthropic/claude-opus-4.6", **kwargs):
|
||||
"""Create a HermesCLI with minimal mocking."""
|
||||
import cli as _cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
with (
|
||||
patch("cli.get_tool_definitions", return_value=[]),
|
||||
patch.dict("os.environ", clean_env, clear=False),
|
||||
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
|
||||
):
|
||||
cli = HermesCLI(model=model, **kwargs)
|
||||
return cli
|
||||
|
||||
|
||||
class TestNormalizeModelForProvider:
|
||||
"""_normalize_model_for_provider() trusts user-selected models.
|
||||
|
||||
Only two things happen:
|
||||
1. Provider prefixes are stripped (API needs bare slugs)
|
||||
2. The *untouched default* model is swapped for a Codex model
|
||||
Everything else passes through — the API is the judge.
|
||||
"""
|
||||
|
||||
def test_non_codex_provider_is_noop(self):
|
||||
cli = _make_cli(model="gpt-5.4")
|
||||
changed = cli._normalize_model_for_provider("openrouter")
|
||||
assert changed is False
|
||||
assert cli.model == "gpt-5.4"
|
||||
|
||||
def test_bare_codex_model_passes_through(self):
|
||||
cli = _make_cli(model="gpt-5.3-codex")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is False
|
||||
assert cli.model == "gpt-5.3-codex"
|
||||
|
||||
def test_bare_non_codex_model_passes_through(self):
|
||||
"""gpt-5.4 (no 'codex' suffix) passes through — user chose it."""
|
||||
cli = _make_cli(model="gpt-5.4")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is False
|
||||
assert cli.model == "gpt-5.4"
|
||||
|
||||
def test_any_bare_model_trusted(self):
|
||||
"""Even a non-OpenAI bare model passes through — user explicitly set it."""
|
||||
cli = _make_cli(model="claude-opus-4-6")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
# User explicitly chose this model — we trust them, API will error if wrong
|
||||
assert changed is False
|
||||
assert cli.model == "claude-opus-4-6"
|
||||
|
||||
def test_provider_prefix_stripped(self):
|
||||
"""openai/gpt-5.4 → gpt-5.4 (strip prefix, keep model)."""
|
||||
cli = _make_cli(model="openai/gpt-5.4")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is True
|
||||
assert cli.model == "gpt-5.4"
|
||||
|
||||
def test_any_provider_prefix_stripped(self):
|
||||
"""anthropic/claude-opus-4.6 → claude-opus-4.6 (strip prefix only).
|
||||
User explicitly chose this — let the API decide if it works."""
|
||||
cli = _make_cli(model="anthropic/claude-opus-4.6")
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is True
|
||||
assert cli.model == "claude-opus-4.6"
|
||||
|
||||
def test_opencode_go_prefix_stripped(self):
|
||||
cli = _make_cli(model="opencode-go/kimi-k2.5")
|
||||
cli.api_mode = "chat_completions"
|
||||
changed = cli._normalize_model_for_provider("opencode-go")
|
||||
assert changed is True
|
||||
assert cli.model == "kimi-k2.5"
|
||||
assert cli.api_mode == "chat_completions"
|
||||
|
||||
def test_opencode_zen_claude_sets_messages_mode(self):
|
||||
cli = _make_cli(model="opencode-zen/claude-sonnet-4-6")
|
||||
cli.api_mode = "chat_completions"
|
||||
changed = cli._normalize_model_for_provider("opencode-zen")
|
||||
assert changed is True
|
||||
assert cli.model == "claude-sonnet-4-6"
|
||||
assert cli.api_mode == "anthropic_messages"
|
||||
|
||||
def test_default_model_replaced(self):
|
||||
"""No model configured (empty default) gets swapped for codex."""
|
||||
import cli as _cli_mod
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "",
|
||||
"base_url": "",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
# Don't pass model= so _model_is_default is True
|
||||
with (
|
||||
patch("cli.get_tool_definitions", return_value=[]),
|
||||
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
|
||||
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
|
||||
):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI()
|
||||
|
||||
assert cli._model_is_default is True
|
||||
with patch(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
return_value=["gpt-5.3-codex", "gpt-5.4"],
|
||||
):
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is True
|
||||
# Uses first from available list
|
||||
assert cli.model == "gpt-5.3-codex"
|
||||
|
||||
def test_default_fallback_when_api_fails(self):
|
||||
"""No model configured falls back to gpt-5.3-codex when API unreachable."""
|
||||
import cli as _cli_mod
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "",
|
||||
"base_url": "",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
with (
|
||||
patch("cli.get_tool_definitions", return_value=[]),
|
||||
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
|
||||
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
|
||||
):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI()
|
||||
|
||||
with patch(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
side_effect=Exception("offline"),
|
||||
):
|
||||
changed = cli._normalize_model_for_provider("openai-codex")
|
||||
assert changed is True
|
||||
assert cli.model == "gpt-5.3-codex"
|
||||
132
tests/hermes_cli/test_config_env_expansion.py
Normal file
132
tests/hermes_cli/test_config_env_expansion.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from hermes_cli.config import _expand_env_vars, load_config
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
|
||||
class TestExpandEnvVars:
|
||||
def test_simple_substitution(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("MY_KEY", "secret123")
|
||||
assert _expand_env_vars("${MY_KEY}") == "secret123"
|
||||
|
||||
def test_missing_var_kept_verbatim(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
|
||||
assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
|
||||
|
||||
def test_no_placeholder_unchanged(self):
|
||||
assert _expand_env_vars("plain-value") == "plain-value"
|
||||
|
||||
def test_dict_recursive(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("TOKEN", "tok-abc")
|
||||
result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
|
||||
assert result == {"key": "tok-abc", "other": "literal"}
|
||||
|
||||
def test_nested_dict(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("API_KEY", "sk-xyz")
|
||||
result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
|
||||
assert result["model"]["api_key"] == "sk-xyz"
|
||||
|
||||
def test_list_items(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("VAL", "hello")
|
||||
result = _expand_env_vars(["${VAL}", "literal", 42])
|
||||
assert result == ["hello", "literal", 42]
|
||||
|
||||
def test_non_string_values_untouched(self):
|
||||
assert _expand_env_vars(42) == 42
|
||||
assert _expand_env_vars(3.14) == 3.14
|
||||
assert _expand_env_vars(True) is True
|
||||
assert _expand_env_vars(None) is None
|
||||
|
||||
def test_multiple_placeholders_in_one_string(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("HOST", "localhost")
|
||||
mp.setenv("PORT", "5432")
|
||||
assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
|
||||
|
||||
def test_dict_keys_not_expanded(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("KEY", "value")
|
||||
result = _expand_env_vars({"${KEY}": "no-expand-key"})
|
||||
assert "${KEY}" in result
|
||||
|
||||
|
||||
class TestLoadConfigExpansion:
|
||||
def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"model:\n"
|
||||
" api_key: ${GOOGLE_API_KEY}\n"
|
||||
"platforms:\n"
|
||||
" telegram:\n"
|
||||
" token: ${TELEGRAM_BOT_TOKEN}\n"
|
||||
"plain: no-substitution\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
|
||||
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "gsk-test-key"
|
||||
assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
|
||||
assert config["plain"] == "no-substitution"
|
||||
|
||||
def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = "model:\n api_key: ${NOT_SET_XYZ_123}\n"
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
|
||||
|
||||
|
||||
class TestLoadCliConfigExpansion:
|
||||
"""Verify that load_cli_config() also expands ${VAR} references."""
|
||||
|
||||
def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${TEST_VISION_KEY_XYZ}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
|
||||
# Patch the hermes home so load_cli_config finds our test config
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
|
||||
|
||||
def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${UNSET_CLI_VAR_ABC}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"
|
||||
50
tests/hermes_cli/test_external_credential_detection.py
Normal file
50
tests/hermes_cli/test_external_credential_detection.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for detect_external_credentials() -- Phase 2 credential sync."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import detect_external_credentials
|
||||
|
||||
|
||||
class TestDetectCodexCLI:
|
||||
def test_detects_valid_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
auth = codex_dir / "auth.json"
|
||||
auth.write_text(json.dumps({
|
||||
"tokens": {"access_token": "tok-123", "refresh_token": "ref-456"}
|
||||
}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
codex_hits = [c for c in result if c["provider"] == "openai-codex"]
|
||||
assert len(codex_hits) == 1
|
||||
assert "Codex CLI" in codex_hits[0]["label"]
|
||||
|
||||
def test_skips_codex_without_access_token(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text(json.dumps({"tokens": {}}))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_missing_codex_dir(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_skips_malformed_codex_auth(self, tmp_path, monkeypatch):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
codex_dir.mkdir()
|
||||
(codex_dir / "auth.json").write_text("{bad json")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_dir))
|
||||
result = detect_external_credentials()
|
||||
assert not any(c["provider"] == "openai-codex" for c in result)
|
||||
|
||||
def test_returns_empty_when_nothing_found(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
|
||||
result = detect_external_credentials()
|
||||
assert result == []
|
||||
273
tests/hermes_cli/test_gemini_provider.py
Normal file
273
tests/hermes_cli/test_gemini_provider.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Tests for Google AI Studio (Gemini) provider integration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials
|
||||
from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider, detect_vendor
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models, _NOISE_PATTERNS
|
||||
|
||||
|
||||
# ── Provider Registry ──
|
||||
|
||||
class TestGeminiProviderRegistry:
|
||||
def test_gemini_in_registry(self):
|
||||
assert "gemini" in PROVIDER_REGISTRY
|
||||
|
||||
def test_gemini_config(self):
|
||||
pconfig = PROVIDER_REGISTRY["gemini"]
|
||||
assert pconfig.id == "gemini"
|
||||
assert pconfig.name == "Google AI Studio"
|
||||
assert pconfig.auth_type == "api_key"
|
||||
assert pconfig.inference_base_url == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
def test_gemini_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["gemini"]
|
||||
assert pconfig.api_key_env_vars == ("GOOGLE_API_KEY", "GEMINI_API_KEY")
|
||||
assert pconfig.base_url_env_var == "GEMINI_BASE_URL"
|
||||
|
||||
def test_gemini_base_url(self):
|
||||
assert "generativelanguage.googleapis.com" in PROVIDER_REGISTRY["gemini"].inference_base_url
|
||||
|
||||
|
||||
# ── Provider Aliases ──
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY", "GEMINI_API_KEY", "GEMINI_BASE_URL",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY", "DEEPSEEK_API_KEY",
|
||||
)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_provider_env(monkeypatch):
|
||||
for var in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
class TestGeminiAliases:
|
||||
def test_explicit_gemini(self):
|
||||
assert resolve_provider("gemini") == "gemini"
|
||||
|
||||
def test_alias_google(self):
|
||||
assert resolve_provider("google") == "gemini"
|
||||
|
||||
def test_alias_google_gemini(self):
|
||||
assert resolve_provider("google-gemini") == "gemini"
|
||||
|
||||
def test_alias_google_ai_studio(self):
|
||||
assert resolve_provider("google-ai-studio") == "gemini"
|
||||
|
||||
def test_models_py_aliases(self):
|
||||
assert _PROVIDER_ALIASES.get("google") == "gemini"
|
||||
assert _PROVIDER_ALIASES.get("google-gemini") == "gemini"
|
||||
assert _PROVIDER_ALIASES.get("google-ai-studio") == "gemini"
|
||||
|
||||
def test_normalize_provider(self):
|
||||
assert normalize_provider("google") == "gemini"
|
||||
assert normalize_provider("gemini") == "gemini"
|
||||
assert normalize_provider("google-ai-studio") == "gemini"
|
||||
|
||||
|
||||
# ── Auto-detection ──
|
||||
|
||||
class TestGeminiAutoDetection:
|
||||
def test_auto_detects_google_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-google-key")
|
||||
assert resolve_provider("auto") == "gemini"
|
||||
|
||||
def test_auto_detects_gemini_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
|
||||
assert resolve_provider("auto") == "gemini"
|
||||
|
||||
def test_google_api_key_priority_over_gemini(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "primary-key")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "alias-key")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["api_key"] == "primary-key"
|
||||
assert creds["source"] == "GOOGLE_API_KEY"
|
||||
|
||||
|
||||
# ── Credential Resolution ──
|
||||
|
||||
class TestGeminiCredentials:
|
||||
def test_resolve_with_google_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "google-secret")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["provider"] == "gemini"
|
||||
assert creds["api_key"] == "google-secret"
|
||||
assert creds["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
def test_resolve_with_gemini_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "gemini-secret")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["api_key"] == "gemini-secret"
|
||||
|
||||
def test_resolve_with_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "key")
|
||||
monkeypatch.setenv("GEMINI_BASE_URL", "https://custom.endpoint/v1")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["base_url"] == "https://custom.endpoint/v1"
|
||||
|
||||
def test_runtime_gemini(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "google-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="gemini")
|
||||
assert result["provider"] == "gemini"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "google-key"
|
||||
assert result["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
|
||||
# ── Model Catalog ──
|
||||
|
||||
class TestGeminiModelCatalog:
|
||||
def test_provider_models_exist(self):
|
||||
assert "gemini" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-2.5-pro" in models
|
||||
assert "gemini-2.5-flash" in models
|
||||
assert "gemma-4-31b-it" in models
|
||||
|
||||
def test_provider_models_has_3x(self):
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-3.1-pro-preview" in models
|
||||
assert "gemini-3-flash-preview" in models
|
||||
assert "gemini-3.1-flash-lite-preview" in models
|
||||
|
||||
def test_provider_label(self):
|
||||
assert "gemini" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["gemini"] == "Google AI Studio"
|
||||
|
||||
|
||||
# ── Model Normalization ──
|
||||
|
||||
class TestGeminiModelNormalization:
|
||||
def test_passthrough_bare_name(self):
|
||||
assert normalize_model_for_provider("gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
|
||||
|
||||
def test_strip_vendor_prefix(self):
|
||||
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "google/gemini-2.5-flash"
|
||||
|
||||
def test_gemma_vendor_detection(self):
|
||||
assert detect_vendor("gemma-4-31b-it") == "google"
|
||||
|
||||
def test_gemini_vendor_detection(self):
|
||||
assert detect_vendor("gemini-2.5-flash") == "google"
|
||||
|
||||
def test_aggregator_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gemini-2.5-flash", "openrouter")
|
||||
assert result == "google/gemini-2.5-flash"
|
||||
|
||||
def test_gemma_aggregator_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gemma-4-31b-it", "openrouter")
|
||||
assert result == "google/gemma-4-31b-it"
|
||||
|
||||
|
||||
# ── Context Length ──
|
||||
|
||||
class TestGeminiContextLength:
|
||||
def test_gemma_4_31b_context(self):
|
||||
# Mock external API lookups to test against hardcoded defaults
|
||||
# (models.dev and OpenRouter may return different values like 262144).
|
||||
with patch("agent.models_dev.lookup_models_dev_context", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}):
|
||||
ctx = get_model_context_length("gemma-4-31b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemma_4_26b_context(self):
|
||||
ctx = get_model_context_length("gemma-4-26b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemini_3_context(self):
|
||||
ctx = get_model_context_length("gemini-3.1-pro-preview", provider="gemini")
|
||||
assert ctx == 1048576
|
||||
|
||||
|
||||
# ── Agent Init (no SyntaxError) ──
|
||||
|
||||
class TestGeminiAgentInit:
|
||||
def test_agent_imports_without_error(self):
|
||||
"""Verify run_agent.py has no SyntaxError (the critical bug)."""
|
||||
import importlib
|
||||
import run_agent
|
||||
importlib.reload(run_agent)
|
||||
|
||||
def test_gemini_agent_uses_chat_completions(self, monkeypatch):
|
||||
"""Gemini falls through to chat_completions — no special elif needed."""
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="test-key",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
)
|
||||
assert agent.api_mode == "chat_completions"
|
||||
assert agent.provider == "gemini"
|
||||
|
||||
|
||||
# ── models.dev Integration ──
|
||||
|
||||
class TestGeminiModelsDev:
|
||||
def test_gemini_mapped_to_google(self):
|
||||
assert PROVIDER_TO_MODELS_DEV.get("gemini") == "google"
|
||||
|
||||
def test_noise_filter_excludes_tts(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-pro-preview-tts")
|
||||
|
||||
def test_noise_filter_excludes_dated_preview(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-flash-preview-04-17")
|
||||
|
||||
def test_noise_filter_excludes_embedding(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-embedding-001")
|
||||
|
||||
def test_noise_filter_excludes_live(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-live-2.5-flash")
|
||||
|
||||
def test_noise_filter_excludes_image(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-flash-image")
|
||||
|
||||
def test_noise_filter_excludes_customtools(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-3.1-pro-preview-customtools")
|
||||
|
||||
def test_noise_filter_passes_stable(self):
|
||||
assert not _NOISE_PATTERNS.search("gemini-2.5-flash")
|
||||
|
||||
def test_noise_filter_passes_preview(self):
|
||||
# Non-dated preview (e.g. gemini-3-flash-preview) should pass
|
||||
assert not _NOISE_PATTERNS.search("gemini-3-flash-preview")
|
||||
|
||||
def test_noise_filter_passes_gemma(self):
|
||||
assert not _NOISE_PATTERNS.search("gemma-4-31b-it")
|
||||
|
||||
def test_list_agentic_models_with_mock_data(self):
|
||||
"""list_agentic_models filters correctly from mock models.dev data."""
|
||||
mock_data = {
|
||||
"google": {
|
||||
"models": {
|
||||
"gemini-3-flash-preview": {"tool_call": True},
|
||||
"gemini-2.5-pro": {"tool_call": True},
|
||||
"gemini-embedding-001": {"tool_call": False},
|
||||
"gemini-2.5-flash-preview-tts": {"tool_call": False},
|
||||
"gemini-live-2.5-flash": {"tool_call": True},
|
||||
"gemini-2.5-flash-preview-04-17": {"tool_call": True},
|
||||
"gemma-4-31b-it": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_data):
|
||||
result = list_agentic_models("gemini")
|
||||
assert "gemini-3-flash-preview" in result
|
||||
assert "gemini-2.5-pro" in result
|
||||
assert "gemma-4-31b-it" in result
|
||||
# Filtered out:
|
||||
assert "gemini-embedding-001" not in result # no tool_call
|
||||
assert "gemini-2.5-flash-preview-tts" not in result # no tool_call
|
||||
assert "gemini-live-2.5-flash" not in result # noise: live-
|
||||
assert "gemini-2.5-flash-preview-04-17" not in result # noise: dated preview
|
||||
116
tests/hermes_cli/test_model_normalize.py
Normal file
116
tests/hermes_cli/test_model_normalize.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for hermes_cli.model_normalize — provider-aware model name normalization.
|
||||
|
||||
Covers issue #5211: opencode-go model names with dots (e.g. minimax-m2.7)
|
||||
must NOT be mangled to hyphens (minimax-m2-7).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_cli.model_normalize import (
|
||||
normalize_model_for_provider,
|
||||
_DOT_TO_HYPHEN_PROVIDERS,
|
||||
_AGGREGATOR_PROVIDERS,
|
||||
detect_vendor,
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: issue #5211 ────────────────────────────────────────────
|
||||
|
||||
class TestIssue5211OpenCodeGoDotPreservation:
|
||||
"""OpenCode Go model names with dots must pass through unchanged."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("minimax-m2.7", "minimax-m2.7"),
|
||||
("minimax-m2.5", "minimax-m2.5"),
|
||||
("glm-4.5", "glm-4.5"),
|
||||
("kimi-k2.5", "kimi-k2.5"),
|
||||
("some-model-1.0.3", "some-model-1.0.3"),
|
||||
])
|
||||
def test_opencode_go_preserves_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "opencode-go")
|
||||
assert result == expected, f"Expected {expected!r}, got {result!r}"
|
||||
|
||||
def test_opencode_go_not_in_dot_to_hyphen_set(self):
|
||||
"""opencode-go must NOT be in the dot-to-hyphen provider set."""
|
||||
assert "opencode-go" not in _DOT_TO_HYPHEN_PROVIDERS
|
||||
|
||||
|
||||
# ── Anthropic dot-to-hyphen conversion (regression) ────────────────────
|
||||
|
||||
class TestAnthropicDotToHyphen:
|
||||
"""Anthropic API still needs dots→hyphens."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4-6"),
|
||||
("claude-opus-4.5", "claude-opus-4-5"),
|
||||
])
|
||||
def test_anthropic_converts_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "anthropic")
|
||||
assert result == expected
|
||||
|
||||
def test_anthropic_strips_vendor_prefix(self):
|
||||
result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic")
|
||||
assert result == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ── OpenCode Zen regression ────────────────────────────────────────────
|
||||
|
||||
class TestOpenCodeZenDotToHyphen:
|
||||
"""OpenCode Zen follows Anthropic convention (dots→hyphens)."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4-6"),
|
||||
("glm-4.5", "glm-4-5"),
|
||||
])
|
||||
def test_zen_converts_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "opencode-zen")
|
||||
assert result == expected
|
||||
|
||||
def test_zen_strips_vendor_prefix(self):
|
||||
result = normalize_model_for_provider("opencode-zen/claude-sonnet-4.6", "opencode-zen")
|
||||
assert result == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ── Copilot dot preservation (regression) ──────────────────────────────
|
||||
|
||||
class TestCopilotDotPreservation:
|
||||
"""Copilot preserves dots in model names."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("gpt-5.4", "gpt-5.4"),
|
||||
])
|
||||
def test_copilot_preserves_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "copilot")
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ── Aggregator providers (regression) ──────────────────────────────────
|
||||
|
||||
class TestAggregatorProviders:
|
||||
"""Aggregators need vendor/model slugs."""
|
||||
|
||||
def test_openrouter_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("claude-sonnet-4.6", "openrouter")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_nous_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gpt-5.4", "nous")
|
||||
assert result == "openai/gpt-5.4"
|
||||
|
||||
def test_vendor_already_present(self):
|
||||
result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "openrouter")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
# ── detect_vendor ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectVendor:
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "anthropic"),
|
||||
("gpt-5.4-mini", "openai"),
|
||||
("minimax-m2.7", "minimax"),
|
||||
("glm-4.5", "z-ai"),
|
||||
("kimi-k2.5", "moonshotai"),
|
||||
])
|
||||
def test_detects_known_vendors(self, model, expected):
|
||||
assert detect_vendor(model) == expected
|
||||
259
tests/hermes_cli/test_model_provider_persistence.py
Normal file
259
tests/hermes_cli/test_model_provider_persistence.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Tests that provider selection via `hermes model` always persists correctly.
|
||||
|
||||
Regression tests for the bug where _save_model_choice could save config.model
|
||||
as a plain string, causing subsequent provider writes (which check
|
||||
isinstance(model, dict)) to silently fail — leaving the provider unset and
|
||||
falling back to auto-detection.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with a minimal string-format config."""
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
config_yaml = home / "config.yaml"
|
||||
# Start with model as a plain string — the format that triggered the bug
|
||||
config_yaml.write_text("model: some-old-model\n")
|
||||
env_file = home / ".env"
|
||||
env_file.write_text("")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# Clear env vars that could interfere
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
return home
|
||||
|
||||
|
||||
class TestSaveModelChoiceAlwaysDict:
|
||||
def test_string_model_becomes_dict(self, config_home):
|
||||
"""When config.model is a plain string, _save_model_choice must
|
||||
convert it to a dict so provider can be set afterwards."""
|
||||
from hermes_cli.auth import _save_model_choice
|
||||
|
||||
_save_model_choice("kimi-k2.5")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), (
|
||||
f"Expected model to be a dict after save, got {type(model)}: {model}"
|
||||
)
|
||||
assert model["default"] == "kimi-k2.5"
|
||||
|
||||
def test_dict_model_stays_dict(self, config_home):
|
||||
"""When config.model is already a dict, _save_model_choice preserves it."""
|
||||
import yaml
|
||||
(config_home / "config.yaml").write_text(
|
||||
"model:\n default: old-model\n provider: openrouter\n"
|
||||
)
|
||||
from hermes_cli.auth import _save_model_choice
|
||||
|
||||
_save_model_choice("new-model")
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "new-model"
|
||||
assert model["provider"] == "openrouter" # preserved
|
||||
|
||||
|
||||
class TestProviderPersistsAfterModelSave:
|
||||
def test_api_key_provider_saved_when_model_was_string(self, config_home, monkeypatch):
|
||||
"""_model_flow_api_key_provider must persist the provider even when
|
||||
config.model started as a plain string."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get("kimi-coding")
|
||||
if not pconfig:
|
||||
pytest.skip("kimi-coding not in PROVIDER_REGISTRY")
|
||||
|
||||
# Simulate: user has a Kimi API key, model was a string
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key")
|
||||
|
||||
from hermes_cli.main import _model_flow_api_key_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
# Mock the model selection prompt to return "kimi-k2.5"
|
||||
# Also mock input() for the base URL prompt and builtins.input
|
||||
with patch("hermes_cli.auth._prompt_model_selection", return_value="kimi-k2.5"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("builtins.input", return_value=""):
|
||||
_model_flow_api_key_provider(load_config(), "kimi-coding", "old-model")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "kimi-coding", (
|
||||
f"provider should be 'kimi-coding', got {model.get('provider')}"
|
||||
)
|
||||
assert model.get("default") == "kimi-k2.5"
|
||||
|
||||
def test_copilot_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
return_value=[
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="gpt-5.4",
|
||||
), patch(
|
||||
"hermes_cli.main._prompt_reasoning_effort_selection",
|
||||
return_value="high",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_copilot(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "copilot"
|
||||
assert model.get("base_url") == "https://api.githubcopilot.com"
|
||||
assert model.get("default") == "gpt-5.4"
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
assert config["agent"]["reasoning_effort"] == "high"
|
||||
|
||||
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot_acp
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.get_external_process_provider_status",
|
||||
return_value={
|
||||
"resolved_command": "/usr/local/bin/copilot",
|
||||
"command": "copilot",
|
||||
"base_url": "acp://copilot",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.auth.resolve_external_process_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot-acp",
|
||||
"api_key": "copilot-acp",
|
||||
"base_url": "acp://copilot",
|
||||
"command": "/usr/local/bin/copilot",
|
||||
"args": ["--acp", "--stdio"],
|
||||
"source": "process",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
return_value=[
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="gpt-5.4",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_copilot_acp(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "copilot-acp"
|
||||
assert model.get("base_url") == "acp://copilot"
|
||||
assert model.get("default") == "gpt-5.4"
|
||||
assert model.get("api_mode") == "chat_completions"
|
||||
|
||||
def test_opencode_go_models_are_selectable_and_persist_normalized(self, config_home, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_api_key_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key")
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.7"]), \
|
||||
patch("hermes_cli.auth._prompt_model_selection", return_value="kimi-k2.5"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("builtins.input", return_value=""):
|
||||
_model_flow_api_key_provider(load_config(), "opencode-go", "opencode-go/kimi-k2.5")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "opencode-go"
|
||||
assert model.get("default") == "kimi-k2.5"
|
||||
assert model.get("api_mode") == "chat_completions"
|
||||
|
||||
def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_api_key_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key")
|
||||
(config_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: kimi-k2.5\n"
|
||||
" provider: opencode-go\n"
|
||||
" base_url: https://opencode.ai/zen/go/v1\n"
|
||||
" api_mode: chat_completions\n"
|
||||
)
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.5"]), \
|
||||
patch("hermes_cli.auth._prompt_model_selection", return_value="minimax-m2.5"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("builtins.input", return_value=""):
|
||||
_model_flow_api_key_provider(load_config(), "opencode-go", "kimi-k2.5")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "opencode-go"
|
||||
assert model.get("default") == "minimax-m2.5"
|
||||
assert model.get("api_mode") == "anthropic_messages"
|
||||
657
tests/hermes_cli/test_ollama_cloud_auth.py
Normal file
657
tests/hermes_cli/test_ollama_cloud_auth.py
Normal file
@@ -0,0 +1,657 @@
|
||||
"""Tests for Ollama Cloud authentication and /model switch fixes.
|
||||
|
||||
Covers:
|
||||
- OLLAMA_API_KEY resolution for custom endpoints pointing to ollama.com
|
||||
- Fallback provider passing base_url/api_key to resolve_provider_client
|
||||
- /model command updating requested_provider for session persistence
|
||||
- Direct alias resolution from config.yaml model_aliases
|
||||
- Reverse lookup: full model names match direct aliases
|
||||
- /model tab completion for model aliases
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OLLAMA_API_KEY credential resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOllamaCloudCredentials:
|
||||
"""runtime_provider should use OLLAMA_API_KEY for ollama.com endpoints."""
|
||||
|
||||
def test_ollama_api_key_used_for_ollama_endpoint(self, monkeypatch, tmp_path):
|
||||
"""When base_url contains ollama.com, OLLAMA_API_KEY is in the candidate chain."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key-12345")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
# Mock config to return custom provider with ollama base_url
|
||||
mock_config = {
|
||||
"model": {
|
||||
"default": "qwen3.5:397b",
|
||||
"provider": "custom",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider._get_model_config",
|
||||
lambda: mock_config.get("model", {}),
|
||||
)
|
||||
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert runtime["base_url"] == "https://ollama.com/v1"
|
||||
assert runtime["api_key"] == "test-ollama-key-12345"
|
||||
assert runtime["provider"] == "custom"
|
||||
|
||||
def test_ollama_key_not_used_for_non_ollama_endpoint(self, monkeypatch):
|
||||
"""OLLAMA_API_KEY should NOT be used for non-ollama endpoints."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
mock_config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:11434/v1",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider._get_model_config",
|
||||
lambda: mock_config.get("model", {}),
|
||||
)
|
||||
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider(requested="custom")
|
||||
|
||||
# Should fall through to no-key-required for local endpoints
|
||||
assert runtime["api_key"] != "test-ollama-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct alias resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDirectAliases:
|
||||
"""model_switch direct aliases from config.yaml model_aliases."""
|
||||
|
||||
def test_direct_alias_loaded_from_config(self, monkeypatch):
|
||||
"""Direct aliases load from config.yaml model_aliases section."""
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
"mymodel": {
|
||||
"model": "custom-model:latest",
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
|
||||
assert "mymodel" in aliases
|
||||
assert aliases["mymodel"].model == "custom-model:latest"
|
||||
assert aliases["mymodel"].provider == "custom"
|
||||
assert aliases["mymodel"].base_url == "https://example.com/v1"
|
||||
|
||||
def test_direct_alias_resolved_before_catalog(self, monkeypatch):
|
||||
"""Direct aliases take priority over models.dev catalog lookup."""
|
||||
from hermes_cli.model_switch import DirectAlias, resolve_alias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
result = resolve_alias("glm", "openrouter")
|
||||
assert result is not None
|
||||
provider, model, alias = result
|
||||
assert model == "glm-4.7"
|
||||
assert provider == "custom"
|
||||
assert alias == "glm"
|
||||
|
||||
def test_reverse_lookup_by_model_id(self, monkeypatch):
|
||||
"""Full model names (e.g. 'kimi-k2.5') match via reverse lookup."""
|
||||
from hermes_cli.model_switch import DirectAlias, resolve_alias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"kimi": DirectAlias("kimi-k2.5", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
# Typing full model name should resolve through the alias
|
||||
result = resolve_alias("kimi-k2.5", "openrouter")
|
||||
assert result is not None
|
||||
provider, model, alias = result
|
||||
assert model == "kimi-k2.5"
|
||||
assert provider == "custom"
|
||||
assert alias == "kimi"
|
||||
|
||||
def test_reverse_lookup_case_insensitive(self, monkeypatch):
|
||||
"""Reverse lookup is case-insensitive."""
|
||||
from hermes_cli.model_switch import DirectAlias, resolve_alias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"glm": DirectAlias("GLM-4.7", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
result = resolve_alias("glm-4.7", "openrouter")
|
||||
assert result is not None
|
||||
assert result[1] == "GLM-4.7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model command persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestModelSwitchPersistence:
|
||||
"""CLI /model command should update requested_provider for session persistence."""
|
||||
|
||||
def test_model_switch_result_fields(self):
|
||||
"""ModelSwitchResult has all required fields for CLI state update."""
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
result = ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="claude-opus-4-6",
|
||||
target_provider="anthropic",
|
||||
provider_changed=True,
|
||||
api_key="test-key",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.new_model == "claude-opus-4-6"
|
||||
assert result.target_provider == "anthropic"
|
||||
assert result.api_key == "test-key"
|
||||
assert result.base_url == "https://api.anthropic.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model tab completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestModelTabCompletion:
|
||||
"""SlashCommandCompleter provides model alias completions for /model."""
|
||||
|
||||
def test_model_completions_yields_direct_aliases(self, monkeypatch):
|
||||
"""_model_completions yields direct aliases with model and provider info."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
|
||||
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("", ""))
|
||||
|
||||
names = [c.text for c in completions]
|
||||
assert "opus" in names
|
||||
assert "qwen" in names
|
||||
|
||||
def test_model_completions_filters_by_prefix(self, monkeypatch):
|
||||
"""Completions filter by typed prefix."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"opus": DirectAlias("claude-opus-4-6", "anthropic", ""),
|
||||
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("o", "o"))
|
||||
|
||||
names = [c.text for c in completions]
|
||||
assert "opus" in names
|
||||
assert "qwen" not in names
|
||||
|
||||
def test_model_completions_shows_metadata(self, monkeypatch):
|
||||
"""Completions include model name and provider in display_meta."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer._model_completions("g", "g"))
|
||||
|
||||
assert len(completions) >= 1
|
||||
glm_comp = [c for c in completions if c.text == "glm"][0]
|
||||
meta_str = str(glm_comp.display_meta)
|
||||
assert "glm-4.7" in meta_str
|
||||
assert "custom" in meta_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback base_url passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallbackBaseUrlPassthrough:
|
||||
"""_try_activate_fallback should pass base_url from fallback config."""
|
||||
|
||||
def test_fallback_config_has_base_url(self):
|
||||
"""Verify fallback_providers config structure supports base_url."""
|
||||
# This tests the contract: fallback dicts can have base_url
|
||||
fb = {
|
||||
"provider": "custom",
|
||||
"model": "qwen3.5:397b",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
assert fb.get("base_url") == "https://ollama.com/v1"
|
||||
|
||||
def test_ollama_key_lookup_for_fallback(self, monkeypatch):
|
||||
"""When fallback base_url is ollama.com and no api_key, OLLAMA_API_KEY is used."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "fb-ollama-key")
|
||||
|
||||
fb = {
|
||||
"provider": "custom",
|
||||
"model": "qwen3.5:397b",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
|
||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
|
||||
|
||||
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint:
|
||||
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
|
||||
|
||||
assert fb_api_key_hint == "fb-ollama-key"
|
||||
assert fb_base_url_hint == "https://ollama.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases: _load_direct_aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadDirectAliasesEdgeCases:
|
||||
"""Edge cases for _load_direct_aliases parsing."""
|
||||
|
||||
def test_empty_model_aliases_config(self, monkeypatch):
|
||||
"""Empty model_aliases dict returns only builtins (if any)."""
|
||||
mock_config = {"model_aliases": {}}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert isinstance(aliases, dict)
|
||||
|
||||
def test_model_aliases_not_a_dict(self, monkeypatch):
|
||||
"""Non-dict model_aliases value is gracefully ignored."""
|
||||
mock_config = {"model_aliases": "bad-string-value"}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert isinstance(aliases, dict)
|
||||
|
||||
def test_model_aliases_none_value(self, monkeypatch):
|
||||
"""model_aliases: null in config is handled gracefully."""
|
||||
mock_config = {"model_aliases": None}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert isinstance(aliases, dict)
|
||||
|
||||
def test_malformed_entry_without_model_key(self, monkeypatch):
|
||||
"""Entries missing 'model' key are skipped."""
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
"bad_entry": {
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
},
|
||||
"good_entry": {
|
||||
"model": "valid-model",
|
||||
"provider": "custom",
|
||||
},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert "bad_entry" not in aliases
|
||||
assert "good_entry" in aliases
|
||||
|
||||
def test_malformed_entry_non_dict_value(self, monkeypatch):
|
||||
"""Non-dict entry values are skipped."""
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
"string_entry": "just-a-string",
|
||||
"none_entry": None,
|
||||
"list_entry": ["a", "b"],
|
||||
"good": {"model": "real-model", "provider": "custom"},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert "string_entry" not in aliases
|
||||
assert "none_entry" not in aliases
|
||||
assert "list_entry" not in aliases
|
||||
assert "good" in aliases
|
||||
|
||||
def test_load_config_exception_returns_builtins(self, monkeypatch):
|
||||
"""If load_config raises, _load_direct_aliases returns builtins only."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("config broken")),
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert isinstance(aliases, dict)
|
||||
|
||||
def test_alias_name_normalized_lowercase(self, monkeypatch):
|
||||
"""Alias names are lowercased and stripped."""
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
" MyModel ": {
|
||||
"model": "my-model:latest",
|
||||
"provider": "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert "mymodel" in aliases
|
||||
assert " MyModel " not in aliases
|
||||
|
||||
def test_empty_model_string_skipped(self, monkeypatch):
|
||||
"""Entries with empty model string are skipped."""
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
"empty": {"model": "", "provider": "custom"},
|
||||
"good": {"model": "real", "provider": "custom"},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
|
||||
from hermes_cli.model_switch import _load_direct_aliases
|
||||
aliases = _load_direct_aliases()
|
||||
assert "empty" not in aliases
|
||||
assert "good" in aliases
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ensure_direct_aliases idempotency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnsureDirectAliases:
|
||||
"""_ensure_direct_aliases lazy-loading behavior."""
|
||||
|
||||
def test_ensure_populates_on_first_call(self, monkeypatch):
|
||||
"""DIRECT_ALIASES is populated after _ensure_direct_aliases."""
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
mock_config = {
|
||||
"model_aliases": {
|
||||
"test": {"model": "test-model", "provider": "custom"},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: mock_config,
|
||||
)
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", {})
|
||||
ms._ensure_direct_aliases()
|
||||
assert "test" in ms.DIRECT_ALIASES
|
||||
|
||||
def test_ensure_no_reload_when_populated(self, monkeypatch):
|
||||
"""_ensure_direct_aliases does not reload if already populated."""
|
||||
import hermes_cli.model_switch as ms
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
|
||||
existing = {"pre": DirectAlias("pre-model", "custom", "")}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", existing)
|
||||
|
||||
call_count = [0]
|
||||
original_load = ms._load_direct_aliases
|
||||
def counting_load():
|
||||
call_count[0] += 1
|
||||
return original_load()
|
||||
monkeypatch.setattr(ms, "_load_direct_aliases", counting_load)
|
||||
|
||||
ms._ensure_direct_aliases()
|
||||
assert call_count[0] == 0
|
||||
assert "pre" in ms.DIRECT_ALIASES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_alias: fallthrough and edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveAliasEdgeCases:
|
||||
"""Edge cases for resolve_alias."""
|
||||
|
||||
def test_unknown_alias_returns_none(self, monkeypatch):
|
||||
"""Unknown alias not in direct or catalog returns None."""
|
||||
import hermes_cli.model_switch as ms
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", {})
|
||||
|
||||
result = ms.resolve_alias("nonexistent_model_xyz", "openrouter")
|
||||
assert result is None
|
||||
|
||||
def test_whitespace_input_handled(self, monkeypatch):
|
||||
"""Input with whitespace is stripped before lookup."""
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"myalias": DirectAlias("my-model", "custom", "https://example.com"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
result = ms.resolve_alias(" myalias ", "openrouter")
|
||||
assert result is not None
|
||||
assert result[1] == "my-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# switch_model: direct alias base_url override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSwitchModelDirectAliasOverride:
|
||||
"""switch_model should use base_url from direct alias."""
|
||||
|
||||
def test_switch_model_uses_alias_base_url(self, monkeypatch):
|
||||
"""When resolved alias has base_url, switch_model should use it."""
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
|
||||
monkeypatch.setattr(ms, "resolve_alias",
|
||||
lambda raw, prov: ("custom", "qwen3.5:397b", "qwen"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.models.validate_requested_model",
|
||||
lambda *a, **kw: {"accepted": True, "persist": True, "recognized": True, "message": None})
|
||||
monkeypatch.setattr("hermes_cli.models.opencode_model_api_mode",
|
||||
lambda *a, **kw: "openai_compat")
|
||||
|
||||
result = ms.switch_model("qwen", "openrouter", "old-model")
|
||||
assert result.success
|
||||
assert result.base_url == "https://ollama.com/v1"
|
||||
assert result.new_model == "qwen3.5:397b"
|
||||
|
||||
def test_switch_model_alias_no_api_key_gets_default(self, monkeypatch):
|
||||
"""When alias has base_url but no api_key, 'no-key-required' is set."""
|
||||
from hermes_cli.model_switch import DirectAlias
|
||||
import hermes_cli.model_switch as ms
|
||||
|
||||
test_aliases = {
|
||||
"local": DirectAlias("local-model", "custom", "http://localhost:11434/v1"),
|
||||
}
|
||||
monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases)
|
||||
monkeypatch.setattr(ms, "resolve_alias",
|
||||
lambda raw, prov: ("custom", "local-model", "local"))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.models.validate_requested_model",
|
||||
lambda *a, **kw: {"accepted": True, "persist": True, "recognized": True, "message": None})
|
||||
monkeypatch.setattr("hermes_cli.models.opencode_model_api_mode",
|
||||
lambda *a, **kw: "openai_compat")
|
||||
|
||||
result = ms.switch_model("local", "openrouter", "old-model")
|
||||
assert result.success
|
||||
assert result.api_key == "no-key-required"
|
||||
assert result.base_url == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI state update: requested_provider persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCLIStateUpdate:
|
||||
"""CLI /model handler should update requested_provider and explicit fields."""
|
||||
|
||||
def test_model_switch_result_has_provider_label(self):
|
||||
"""ModelSwitchResult supports provider_label for display."""
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
result = ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="qwen3.5:397b",
|
||||
target_provider="custom",
|
||||
provider_changed=True,
|
||||
api_key="key",
|
||||
base_url="https://ollama.com/v1",
|
||||
api_mode="openai_compat",
|
||||
provider_label="Ollama Cloud",
|
||||
)
|
||||
assert result.provider_label == "Ollama Cloud"
|
||||
|
||||
def test_model_switch_result_defaults(self):
|
||||
"""ModelSwitchResult has sensible defaults."""
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
result = ModelSwitchResult(
|
||||
success=False,
|
||||
new_model="",
|
||||
target_provider="",
|
||||
provider_changed=False,
|
||||
error_message="Something failed",
|
||||
)
|
||||
assert not result.success
|
||||
assert result.error_message == "Something failed"
|
||||
assert result.api_key is None or result.api_key == ""
|
||||
assert result.base_url is None or result.base_url == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback: OLLAMA_API_KEY edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallbackEdgeCases:
|
||||
"""Edge cases for fallback OLLAMA_API_KEY logic."""
|
||||
|
||||
def test_ollama_key_not_injected_for_localhost(self, monkeypatch):
|
||||
"""OLLAMA_API_KEY should not be injected for localhost URLs."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "should-not-use")
|
||||
|
||||
fb = {
|
||||
"provider": "custom",
|
||||
"model": "local-model",
|
||||
"base_url": "http://localhost:11434/v1",
|
||||
}
|
||||
|
||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
|
||||
|
||||
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint:
|
||||
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
|
||||
|
||||
assert fb_api_key_hint is None
|
||||
|
||||
def test_explicit_api_key_not_overridden_by_ollama_key(self, monkeypatch):
|
||||
"""Explicit api_key in fallback config is not overridden by OLLAMA_API_KEY."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "env-key")
|
||||
|
||||
fb = {
|
||||
"provider": "custom",
|
||||
"model": "qwen3.5:397b",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
"api_key": "explicit-key",
|
||||
}
|
||||
|
||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
|
||||
|
||||
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint:
|
||||
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
|
||||
|
||||
assert fb_api_key_hint == "explicit-key"
|
||||
|
||||
def test_no_base_url_in_fallback(self, monkeypatch):
|
||||
"""Fallback with no base_url doesn't crash."""
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "some-key")
|
||||
|
||||
fb = {"provider": "openrouter", "model": "some-model"}
|
||||
|
||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
|
||||
|
||||
if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint:
|
||||
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
|
||||
|
||||
assert fb_base_url_hint is None
|
||||
assert fb_api_key_hint is None
|
||||
256
tests/hermes_cli/test_plugin_cli_registration.py
Normal file
256
tests/hermes_cli/test_plugin_cli_registration.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Tests for plugin CLI registration system.
|
||||
|
||||
Covers:
|
||||
- PluginContext.register_cli_command()
|
||||
- PluginManager._cli_commands storage
|
||||
- get_plugin_cli_commands() convenience function
|
||||
- Memory plugin CLI discovery (discover_plugin_cli_commands)
|
||||
- Honcho register_cli() builds correct argparse tree
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.plugins import (
|
||||
PluginContext,
|
||||
PluginManager,
|
||||
PluginManifest,
|
||||
get_plugin_cli_commands,
|
||||
)
|
||||
|
||||
|
||||
# ── PluginContext.register_cli_command ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterCliCommand:
|
||||
def _make_ctx(self):
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin")
|
||||
return PluginContext(manifest, mgr), mgr
|
||||
|
||||
def test_registers_command(self):
|
||||
ctx, mgr = self._make_ctx()
|
||||
setup = MagicMock()
|
||||
handler = MagicMock()
|
||||
ctx.register_cli_command(
|
||||
name="mycmd",
|
||||
help="Do something",
|
||||
setup_fn=setup,
|
||||
handler_fn=handler,
|
||||
description="Full description",
|
||||
)
|
||||
assert "mycmd" in mgr._cli_commands
|
||||
entry = mgr._cli_commands["mycmd"]
|
||||
assert entry["name"] == "mycmd"
|
||||
assert entry["help"] == "Do something"
|
||||
assert entry["setup_fn"] is setup
|
||||
assert entry["handler_fn"] is handler
|
||||
assert entry["plugin"] == "test-plugin"
|
||||
|
||||
def test_overwrites_on_duplicate(self):
|
||||
ctx, mgr = self._make_ctx()
|
||||
ctx.register_cli_command("x", "first", MagicMock())
|
||||
ctx.register_cli_command("x", "second", MagicMock())
|
||||
assert mgr._cli_commands["x"]["help"] == "second"
|
||||
|
||||
def test_handler_optional(self):
|
||||
ctx, mgr = self._make_ctx()
|
||||
ctx.register_cli_command("nocb", "test", MagicMock())
|
||||
assert mgr._cli_commands["nocb"]["handler_fn"] is None
|
||||
|
||||
|
||||
class TestGetPluginCliCommands:
|
||||
def test_returns_dict(self):
|
||||
mgr = PluginManager()
|
||||
mgr._cli_commands["foo"] = {"name": "foo", "help": "bar"}
|
||||
with patch("hermes_cli.plugins.get_plugin_manager", return_value=mgr):
|
||||
cmds = get_plugin_cli_commands()
|
||||
assert cmds == {"foo": {"name": "foo", "help": "bar"}}
|
||||
# Top-level is a copy — adding to result doesn't affect manager
|
||||
cmds["new"] = {"name": "new"}
|
||||
assert "new" not in mgr._cli_commands
|
||||
|
||||
|
||||
# ── Memory plugin CLI discovery ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMemoryPluginCliDiscovery:
|
||||
def test_discovers_active_plugin_with_register_cli(self, tmp_path, monkeypatch):
|
||||
"""Only the active memory provider's CLI commands are discovered."""
|
||||
plugin_dir = tmp_path / "testplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("pass\n")
|
||||
(plugin_dir / "cli.py").write_text(
|
||||
"def register_cli(subparser):\n"
|
||||
" subparser.add_argument('--test')\n"
|
||||
"\n"
|
||||
"def testplugin_command(args):\n"
|
||||
" pass\n"
|
||||
)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
"name: testplugin\ndescription: A test plugin\n"
|
||||
)
|
||||
|
||||
# Also create a second plugin that should NOT be discovered
|
||||
other_dir = tmp_path / "otherplugin"
|
||||
other_dir.mkdir()
|
||||
(other_dir / "__init__.py").write_text("pass\n")
|
||||
(other_dir / "cli.py").write_text(
|
||||
"def register_cli(subparser):\n"
|
||||
" subparser.add_argument('--other')\n"
|
||||
)
|
||||
|
||||
import plugins.memory as pm
|
||||
original_dir = pm._MEMORY_PLUGINS_DIR
|
||||
mod_key = "plugins.memory.testplugin.cli"
|
||||
sys.modules.pop(mod_key, None)
|
||||
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path)
|
||||
# Set testplugin as the active provider
|
||||
monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "testplugin")
|
||||
try:
|
||||
cmds = pm.discover_plugin_cli_commands()
|
||||
finally:
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir)
|
||||
sys.modules.pop(mod_key, None)
|
||||
|
||||
# Only testplugin should be discovered, not otherplugin
|
||||
assert len(cmds) == 1
|
||||
assert cmds[0]["name"] == "testplugin"
|
||||
assert cmds[0]["help"] == "A test plugin"
|
||||
assert callable(cmds[0]["setup_fn"])
|
||||
assert cmds[0]["handler_fn"].__name__ == "testplugin_command"
|
||||
|
||||
def test_returns_nothing_when_no_active_provider(self, tmp_path, monkeypatch):
|
||||
"""No commands when memory.provider is not set in config."""
|
||||
plugin_dir = tmp_path / "testplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("pass\n")
|
||||
(plugin_dir / "cli.py").write_text(
|
||||
"def register_cli(subparser):\n pass\n"
|
||||
)
|
||||
|
||||
import plugins.memory as pm
|
||||
original_dir = pm._MEMORY_PLUGINS_DIR
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path)
|
||||
monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: None)
|
||||
try:
|
||||
cmds = pm.discover_plugin_cli_commands()
|
||||
finally:
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir)
|
||||
|
||||
assert len(cmds) == 0
|
||||
|
||||
def test_skips_plugin_without_register_cli(self, tmp_path, monkeypatch):
|
||||
"""An active plugin with cli.py but no register_cli returns nothing."""
|
||||
plugin_dir = tmp_path / "noplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("pass\n")
|
||||
(plugin_dir / "cli.py").write_text("def some_other_fn():\n pass\n")
|
||||
|
||||
import plugins.memory as pm
|
||||
original_dir = pm._MEMORY_PLUGINS_DIR
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path)
|
||||
monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "noplugin")
|
||||
try:
|
||||
cmds = pm.discover_plugin_cli_commands()
|
||||
finally:
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir)
|
||||
sys.modules.pop("plugins.memory.noplugin.cli", None)
|
||||
|
||||
assert len(cmds) == 0
|
||||
|
||||
def test_skips_plugin_without_cli_py(self, tmp_path, monkeypatch):
|
||||
"""An active provider without cli.py returns nothing."""
|
||||
plugin_dir = tmp_path / "nocli"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("pass\n")
|
||||
|
||||
import plugins.memory as pm
|
||||
original_dir = pm._MEMORY_PLUGINS_DIR
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", tmp_path)
|
||||
monkeypatch.setattr(pm, "_get_active_memory_provider", lambda: "nocli")
|
||||
try:
|
||||
cmds = pm.discover_plugin_cli_commands()
|
||||
finally:
|
||||
monkeypatch.setattr(pm, "_MEMORY_PLUGINS_DIR", original_dir)
|
||||
|
||||
assert len(cmds) == 0
|
||||
|
||||
|
||||
# ── Honcho register_cli ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHonchoRegisterCli:
|
||||
def test_builds_subcommand_tree(self):
|
||||
"""register_cli creates the expected subparser tree."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Verify key subcommands exist by parsing them
|
||||
args = parser.parse_args(["status"])
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
args = parser.parse_args(["peer", "--user", "alice"])
|
||||
assert args.honcho_command == "peer"
|
||||
assert args.user == "alice"
|
||||
|
||||
args = parser.parse_args(["mode", "tools"])
|
||||
assert args.honcho_command == "mode"
|
||||
assert args.mode == "tools"
|
||||
|
||||
args = parser.parse_args(["tokens", "--context", "500"])
|
||||
assert args.honcho_command == "tokens"
|
||||
assert args.context == 500
|
||||
|
||||
args = parser.parse_args(["--target-profile", "coder", "status"])
|
||||
assert args.target_profile == "coder"
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
def test_setup_redirects_to_memory_setup(self):
|
||||
"""hermes honcho setup redirects to memory setup."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
args = parser.parse_args(["setup"])
|
||||
assert args.honcho_command == "setup"
|
||||
|
||||
def test_mode_choices_are_recall_modes(self):
|
||||
"""Mode subcommand uses recall mode choices (hybrid/context/tools)."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Valid recall modes should parse
|
||||
for mode in ("hybrid", "context", "tools"):
|
||||
args = parser.parse_args(["mode", mode])
|
||||
assert args.mode == mode
|
||||
|
||||
# Old memoryMode values should fail
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["mode", "honcho"])
|
||||
|
||||
|
||||
# ── ProviderCollector no-op ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProviderCollectorCliNoop:
|
||||
def test_register_cli_command_is_noop(self):
|
||||
"""_ProviderCollector.register_cli_command is a no-op (doesn't crash)."""
|
||||
from plugins.memory import _ProviderCollector
|
||||
|
||||
collector = _ProviderCollector()
|
||||
collector.register_cli_command(
|
||||
name="test", help="test", setup_fn=lambda s: None
|
||||
)
|
||||
# Should not store anything — CLI is discovered via file convention
|
||||
assert not hasattr(collector, "_cli_commands")
|
||||
567
tests/hermes_cli/test_plugins.py
Normal file
567
tests/hermes_cli/test_plugins.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""Tests for the Hermes plugin system (hermes_cli.plugins)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.plugins import (
|
||||
ENTRY_POINTS_GROUP,
|
||||
VALID_HOOKS,
|
||||
LoadedPlugin,
|
||||
PluginContext,
|
||||
PluginManager,
|
||||
PluginManifest,
|
||||
get_plugin_manager,
|
||||
get_plugin_tool_names,
|
||||
discover_plugins,
|
||||
invoke_hook,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
||||
manifest_extra: dict | None = None) -> Path:
|
||||
"""Create a minimal plugin directory with plugin.yaml + __init__.py."""
|
||||
plugin_dir = base / name
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = {"name": name, "version": "0.1.0", "description": f"Test plugin {name}"}
|
||||
if manifest_extra:
|
||||
manifest.update(manifest_extra)
|
||||
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
f"def register(ctx):\n {register_body}\n"
|
||||
)
|
||||
return plugin_dir
|
||||
|
||||
|
||||
# ── TestPluginDiscovery ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginDiscovery:
|
||||
"""Tests for plugin discovery from directories and entry points."""
|
||||
|
||||
def test_discover_user_plugins(self, tmp_path, monkeypatch):
|
||||
"""Plugins in ~/.hermes/plugins/ are discovered."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "hello_plugin")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "hello_plugin" in mgr._plugins
|
||||
assert mgr._plugins["hello_plugin"].enabled
|
||||
|
||||
def test_discover_project_plugins(self, tmp_path, monkeypatch):
|
||||
"""Plugins in ./.hermes/plugins/ are discovered."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "true")
|
||||
plugins_dir = project_dir / ".hermes" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "proj_plugin")
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "proj_plugin" in mgr._plugins
|
||||
assert mgr._plugins["proj_plugin"].enabled
|
||||
|
||||
def test_discover_project_plugins_skipped_by_default(self, tmp_path, monkeypatch):
|
||||
"""Project plugins are not discovered unless explicitly enabled."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
plugins_dir = project_dir / ".hermes" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "proj_plugin")
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "proj_plugin" not in mgr._plugins
|
||||
|
||||
def test_discover_is_idempotent(self, tmp_path, monkeypatch):
|
||||
"""Calling discover_and_load() twice does not duplicate plugins."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "once_plugin")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
mgr.discover_and_load() # second call should no-op
|
||||
|
||||
assert len(mgr._plugins) == 1
|
||||
|
||||
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
|
||||
"""Directories without plugin.yaml are silently skipped."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
(plugins_dir / "no_manifest").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert len(mgr._plugins) == 0
|
||||
|
||||
def test_entry_points_scanned(self, tmp_path, monkeypatch):
|
||||
"""Entry-point based plugins are discovered (mocked)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
fake_module = types.ModuleType("fake_ep_plugin")
|
||||
fake_module.register = lambda ctx: None # type: ignore[attr-defined]
|
||||
|
||||
fake_ep = MagicMock()
|
||||
fake_ep.name = "ep_plugin"
|
||||
fake_ep.value = "fake_ep_plugin:register"
|
||||
fake_ep.group = ENTRY_POINTS_GROUP
|
||||
fake_ep.load.return_value = fake_module
|
||||
|
||||
def fake_entry_points():
|
||||
result = MagicMock()
|
||||
result.select = MagicMock(return_value=[fake_ep])
|
||||
return result
|
||||
|
||||
with patch("importlib.metadata.entry_points", fake_entry_points):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "ep_plugin" in mgr._plugins
|
||||
|
||||
|
||||
# ── TestPluginLoading ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginLoading:
|
||||
"""Tests for plugin module loading."""
|
||||
|
||||
def test_load_missing_init(self, tmp_path, monkeypatch):
|
||||
"""Plugin dir without __init__.py records an error."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "bad_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "bad_plugin"}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "bad_plugin" in mgr._plugins
|
||||
assert not mgr._plugins["bad_plugin"].enabled
|
||||
assert mgr._plugins["bad_plugin"].error is not None
|
||||
|
||||
def test_load_missing_register_fn(self, tmp_path, monkeypatch):
|
||||
"""Plugin without register() function records an error."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "no_reg"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "no_reg"}))
|
||||
(plugin_dir / "__init__.py").write_text("# no register function\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "no_reg" in mgr._plugins
|
||||
assert not mgr._plugins["no_reg"].enabled
|
||||
assert "no register()" in mgr._plugins["no_reg"].error
|
||||
|
||||
def test_load_registers_namespace_module(self, tmp_path, monkeypatch):
|
||||
"""Directory plugins are importable under hermes_plugins.<name>."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "ns_plugin")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
# Clean up any prior namespace module
|
||||
sys.modules.pop("hermes_plugins.ns_plugin", None)
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "hermes_plugins.ns_plugin" in sys.modules
|
||||
|
||||
|
||||
# ── TestPluginHooks ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginHooks:
|
||||
"""Tests for lifecycle hook registration and invocation."""
|
||||
|
||||
def test_valid_hooks_include_request_scoped_api_hooks(self):
|
||||
assert "pre_api_request" in VALID_HOOKS
|
||||
assert "post_api_request" in VALID_HOOKS
|
||||
|
||||
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
||||
"""Registered hooks are called on invoke_hook()."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "hook_plugin",
|
||||
register_body='ctx.register_hook("pre_tool_call", lambda **kw: None)',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Should not raise
|
||||
mgr.invoke_hook("pre_tool_call", tool_name="test", args={}, task_id="t1")
|
||||
|
||||
def test_hook_exception_does_not_propagate(self, tmp_path, monkeypatch):
|
||||
"""A hook callback that raises does NOT crash the caller."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "bad_hook",
|
||||
register_body='ctx.register_hook("post_tool_call", lambda **kw: 1/0)',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Should not raise despite 1/0
|
||||
mgr.invoke_hook("post_tool_call", tool_name="x", args={}, result="r", task_id="")
|
||||
|
||||
def test_hook_return_values_collected(self, tmp_path, monkeypatch):
|
||||
"""invoke_hook() collects non-None return values from callbacks."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "ctx_plugin",
|
||||
register_body=(
|
||||
'ctx.register_hook("pre_llm_call", '
|
||||
'lambda **kw: {"context": "memory from plugin"})'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook("pre_llm_call", session_id="s1", user_message="hi",
|
||||
conversation_history=[], is_first_turn=True, model="test")
|
||||
assert len(results) == 1
|
||||
assert results[0] == {"context": "memory from plugin"}
|
||||
|
||||
def test_hook_none_returns_excluded(self, tmp_path, monkeypatch):
|
||||
"""invoke_hook() excludes None returns from the result list."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "none_hook",
|
||||
register_body='ctx.register_hook("post_llm_call", lambda **kw: None)',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook("post_llm_call", session_id="s1",
|
||||
user_message="hi", assistant_response="bye", model="test")
|
||||
assert results == []
|
||||
|
||||
def test_request_hooks_are_invokeable(self, tmp_path, monkeypatch):
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "request_hook",
|
||||
register_body=(
|
||||
'ctx.register_hook("pre_api_request", '
|
||||
'lambda **kw: {"seen": kw.get("api_call_count"), '
|
||||
'"mc": kw.get("message_count"), "tc": kw.get("tool_count")})'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_api_request",
|
||||
session_id="s1",
|
||||
task_id="t1",
|
||||
model="test",
|
||||
api_call_count=2,
|
||||
message_count=5,
|
||||
tool_count=3,
|
||||
approx_input_tokens=100,
|
||||
request_char_count=400,
|
||||
max_tokens=8192,
|
||||
)
|
||||
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
||||
|
||||
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
||||
"""Registering an unknown hook name logs a warning."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "warn_plugin",
|
||||
register_body='ctx.register_hook("on_banana", lambda **kw: None)',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert any("on_banana" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ── TestPluginContext ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginContext:
|
||||
"""Tests for the PluginContext facade."""
|
||||
|
||||
def test_register_tool_adds_to_registry(self, tmp_path, monkeypatch):
|
||||
"""PluginContext.register_tool() puts the tool in the global registry."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "tool_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "tool_plugin"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
'def register(ctx):\n'
|
||||
' ctx.register_tool(\n'
|
||||
' name="plugin_echo",\n'
|
||||
' toolset="plugin_tool_plugin",\n'
|
||||
' schema={"name": "plugin_echo", "description": "Echo", "parameters": {"type": "object", "properties": {}}},\n'
|
||||
' handler=lambda args, **kw: "echo",\n'
|
||||
' )\n'
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "plugin_echo" in mgr._plugin_tool_names
|
||||
|
||||
from tools.registry import registry
|
||||
assert "plugin_echo" in registry._tools
|
||||
|
||||
|
||||
# ── TestPluginToolVisibility ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginToolVisibility:
|
||||
"""Plugin-registered tools appear in get_tool_definitions()."""
|
||||
|
||||
def test_plugin_tools_in_definitions(self, tmp_path, monkeypatch):
|
||||
"""Plugin tools are included when their toolset is in enabled_toolsets."""
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "vis_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "vis_plugin"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
'def register(ctx):\n'
|
||||
' ctx.register_tool(\n'
|
||||
' name="vis_tool",\n'
|
||||
' toolset="plugin_vis_plugin",\n'
|
||||
' schema={"name": "vis_tool", "description": "Visible", "parameters": {"type": "object", "properties": {}}},\n'
|
||||
' handler=lambda args, **kw: "ok",\n'
|
||||
' )\n'
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
||||
|
||||
from model_tools import get_tool_definitions
|
||||
|
||||
# Plugin tools are included when their toolset is explicitly enabled
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "plugin_vis_plugin"], quiet_mode=True)
|
||||
tool_names = [t["function"]["name"] for t in tools]
|
||||
assert "vis_tool" in tool_names
|
||||
|
||||
# Plugin tools are excluded when only other toolsets are enabled
|
||||
tools2 = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True)
|
||||
tool_names2 = [t["function"]["name"] for t in tools2]
|
||||
assert "vis_tool" not in tool_names2
|
||||
|
||||
# Plugin tools are included when no toolset filter is active (all enabled)
|
||||
tools3 = get_tool_definitions(quiet_mode=True)
|
||||
tool_names3 = [t["function"]["name"] for t in tools3]
|
||||
assert "vis_tool" in tool_names3
|
||||
|
||||
|
||||
# ── TestPluginManagerList ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginManagerList:
|
||||
"""Tests for PluginManager.list_plugins()."""
|
||||
|
||||
def test_list_empty(self):
|
||||
"""Empty manager returns empty list."""
|
||||
mgr = PluginManager()
|
||||
assert mgr.list_plugins() == []
|
||||
|
||||
def test_list_returns_sorted(self, tmp_path, monkeypatch):
|
||||
"""list_plugins() returns results sorted by name."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "zulu")
|
||||
_make_plugin_dir(plugins_dir, "alpha")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
listing = mgr.list_plugins()
|
||||
names = [p["name"] for p in listing]
|
||||
assert names == sorted(names)
|
||||
|
||||
def test_list_with_plugins(self, tmp_path, monkeypatch):
|
||||
"""list_plugins() returns info dicts for each discovered plugin."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "alpha")
|
||||
_make_plugin_dir(plugins_dir, "beta")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
listing = mgr.list_plugins()
|
||||
names = [p["name"] for p in listing]
|
||||
assert "alpha" in names
|
||||
assert "beta" in names
|
||||
for p in listing:
|
||||
assert "enabled" in p
|
||||
assert "tools" in p
|
||||
assert "hooks" in p
|
||||
|
||||
|
||||
|
||||
class TestPreLlmCallTargetRouting:
|
||||
"""Tests for pre_llm_call hook return format with target-aware routing.
|
||||
|
||||
The routing logic lives in run_agent.py, but the return format is collected
|
||||
by invoke_hook(). These tests verify the return format works correctly and
|
||||
that downstream code can route based on the 'target' key.
|
||||
"""
|
||||
|
||||
def _make_pre_llm_plugin(self, plugins_dir, name, return_expr):
|
||||
"""Create a plugin that returns a specific value from pre_llm_call."""
|
||||
_make_plugin_dir(
|
||||
plugins_dir, name,
|
||||
register_body=(
|
||||
f'ctx.register_hook("pre_llm_call", lambda **kw: {return_expr})'
|
||||
),
|
||||
)
|
||||
|
||||
def test_context_dict_returned(self, tmp_path, monkeypatch):
|
||||
"""Plugin returning a context dict is collected by invoke_hook."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "basic_plugin",
|
||||
'{"context": "basic context"}',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_llm_call", session_id="s1", user_message="hi",
|
||||
conversation_history=[], is_first_turn=True, model="test",
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["context"] == "basic context"
|
||||
assert "target" not in results[0]
|
||||
|
||||
def test_plain_string_return(self, tmp_path, monkeypatch):
|
||||
"""Plain string returns are collected as-is (routing treats them as user_message)."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "str_plugin",
|
||||
'"plain string context"',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_llm_call", session_id="s1", user_message="hi",
|
||||
conversation_history=[], is_first_turn=True, model="test",
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0] == "plain string context"
|
||||
|
||||
def test_multiple_plugins_context_collected(self, tmp_path, monkeypatch):
|
||||
"""Multiple plugins returning context are all collected."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "aaa_memory",
|
||||
'{"context": "memory context"}',
|
||||
)
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "bbb_guardrail",
|
||||
'{"context": "guardrail text"}',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_llm_call", session_id="s1", user_message="hi",
|
||||
conversation_history=[], is_first_turn=True, model="test",
|
||||
)
|
||||
assert len(results) == 2
|
||||
contexts = [r["context"] for r in results]
|
||||
assert "memory context" in contexts
|
||||
assert "guardrail text" in contexts
|
||||
|
||||
def test_routing_logic_all_to_user_message(self, tmp_path, monkeypatch):
|
||||
"""Simulate the routing logic from run_agent.py.
|
||||
|
||||
All plugin context — dicts and plain strings — ends up in a single
|
||||
user message context string. There is no system_prompt target.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "aaa_mem",
|
||||
'{"context": "memory A"}',
|
||||
)
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "bbb_guard",
|
||||
'{"context": "rule B"}',
|
||||
)
|
||||
self._make_pre_llm_plugin(
|
||||
plugins_dir, "ccc_plain",
|
||||
'"plain text C"',
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_llm_call", session_id="s1", user_message="hi",
|
||||
conversation_history=[], is_first_turn=True, model="test",
|
||||
)
|
||||
|
||||
# Replicate run_agent.py routing logic — everything goes to user msg
|
||||
_ctx_parts = []
|
||||
for r in results:
|
||||
if isinstance(r, dict) and r.get("context"):
|
||||
_ctx_parts.append(str(r["context"]))
|
||||
elif isinstance(r, str) and r.strip():
|
||||
_ctx_parts.append(r)
|
||||
|
||||
assert _ctx_parts == ["memory A", "rule B", "plain text C"]
|
||||
_plugin_user_context = "\n\n".join(_ctx_parts)
|
||||
assert "memory A" in _plugin_user_context
|
||||
assert "rule B" in _plugin_user_context
|
||||
assert "plain text C" in _plugin_user_context
|
||||
|
||||
|
||||
# NOTE: TestPluginCommands removed – register_command() was never implemented
|
||||
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
|
||||
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
|
||||
# integration — all of which are unimplemented features.
|
||||
557
tests/hermes_cli/test_plugins_cmd.py
Normal file
557
tests/hermes_cli/test_plugins_cmd.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""Tests for hermes_cli.plugins_cmd — the ``hermes plugins`` CLI subcommand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.plugins_cmd import (
|
||||
_copy_example_files,
|
||||
_read_manifest,
|
||||
_repo_name_from_url,
|
||||
_resolve_git_url,
|
||||
_sanitize_plugin_name,
|
||||
plugins_command,
|
||||
)
|
||||
|
||||
|
||||
# ── _sanitize_plugin_name ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitizePluginName:
|
||||
"""Reject path-traversal attempts while accepting valid names."""
|
||||
|
||||
def test_valid_simple_name(self, tmp_path):
|
||||
target = _sanitize_plugin_name("my-plugin", tmp_path)
|
||||
assert target == (tmp_path / "my-plugin").resolve()
|
||||
|
||||
def test_valid_name_with_hyphen_and_digits(self, tmp_path):
|
||||
target = _sanitize_plugin_name("plugin-v2", tmp_path)
|
||||
assert target.name == "plugin-v2"
|
||||
|
||||
def test_rejects_dot_dot(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
_sanitize_plugin_name("../../etc/passwd", tmp_path)
|
||||
|
||||
def test_rejects_single_dot_dot(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not reference the plugins directory itself"):
|
||||
_sanitize_plugin_name("..", tmp_path)
|
||||
|
||||
def test_rejects_single_dot(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not reference the plugins directory itself"):
|
||||
_sanitize_plugin_name(".", tmp_path)
|
||||
|
||||
def test_rejects_forward_slash(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
_sanitize_plugin_name("foo/bar", tmp_path)
|
||||
|
||||
def test_rejects_backslash(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
_sanitize_plugin_name("foo\\bar", tmp_path)
|
||||
|
||||
def test_rejects_absolute_path(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
_sanitize_plugin_name("/etc/passwd", tmp_path)
|
||||
|
||||
def test_rejects_empty_name(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
_sanitize_plugin_name("", tmp_path)
|
||||
|
||||
|
||||
# ── _resolve_git_url ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveGitUrl:
|
||||
"""Shorthand and full-URL resolution."""
|
||||
|
||||
def test_owner_repo_shorthand(self):
|
||||
url = _resolve_git_url("owner/repo")
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_https_url_passthrough(self):
|
||||
url = _resolve_git_url("https://github.com/x/y.git")
|
||||
assert url == "https://github.com/x/y.git"
|
||||
|
||||
def test_ssh_url_passthrough(self):
|
||||
url = _resolve_git_url("git@github.com:x/y.git")
|
||||
assert url == "git@github.com:x/y.git"
|
||||
|
||||
def test_http_url_passthrough(self):
|
||||
url = _resolve_git_url("http://example.com/repo.git")
|
||||
assert url == "http://example.com/repo.git"
|
||||
|
||||
def test_file_url_passthrough(self):
|
||||
url = _resolve_git_url("file:///tmp/repo")
|
||||
assert url == "file:///tmp/repo"
|
||||
|
||||
def test_invalid_single_word_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid plugin identifier"):
|
||||
_resolve_git_url("justoneword")
|
||||
|
||||
def test_invalid_three_parts_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid plugin identifier"):
|
||||
_resolve_git_url("a/b/c")
|
||||
|
||||
|
||||
# ── _repo_name_from_url ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRepoNameFromUrl:
|
||||
"""Extract plugin directory name from Git URLs."""
|
||||
|
||||
def test_https_with_dot_git(self):
|
||||
assert (
|
||||
_repo_name_from_url("https://github.com/owner/my-plugin.git") == "my-plugin"
|
||||
)
|
||||
|
||||
def test_https_without_dot_git(self):
|
||||
assert _repo_name_from_url("https://github.com/owner/my-plugin") == "my-plugin"
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert _repo_name_from_url("https://github.com/owner/repo/") == "repo"
|
||||
|
||||
def test_ssh_style(self):
|
||||
assert _repo_name_from_url("git@github.com:owner/repo.git") == "repo"
|
||||
|
||||
def test_ssh_protocol(self):
|
||||
assert _repo_name_from_url("ssh://git@github.com/owner/repo.git") == "repo"
|
||||
|
||||
|
||||
# ── plugins_command dispatch ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginsCommandDispatch:
|
||||
"""Verify alias routing in plugins_command()."""
|
||||
|
||||
def _make_args(self, action, **extras):
|
||||
args = MagicMock()
|
||||
args.plugins_action = action
|
||||
for k, v in extras.items():
|
||||
setattr(args, k, v)
|
||||
return args
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_rm_alias(self, mock_remove):
|
||||
args = self._make_args("rm", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_uninstall_alias(self, mock_remove):
|
||||
args = self._make_args("uninstall", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_list")
|
||||
def test_ls_alias(self, mock_list):
|
||||
args = self._make_args("ls")
|
||||
plugins_command(args)
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_toggle")
|
||||
def test_none_falls_through_to_toggle(self, mock_toggle):
|
||||
args = self._make_args(None)
|
||||
plugins_command(args)
|
||||
mock_toggle.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_install")
|
||||
def test_install_dispatches(self, mock_install):
|
||||
args = self._make_args("install", identifier="owner/repo", force=False)
|
||||
plugins_command(args)
|
||||
mock_install.assert_called_once_with("owner/repo", force=False)
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_update")
|
||||
def test_update_dispatches(self, mock_update):
|
||||
args = self._make_args("update", name="foo")
|
||||
plugins_command(args)
|
||||
mock_update.assert_called_once_with("foo")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_remove_dispatches(self, mock_remove):
|
||||
args = self._make_args("remove", name="bar")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("bar")
|
||||
|
||||
|
||||
# ── _read_manifest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReadManifest:
|
||||
"""Manifest reading edge cases."""
|
||||
|
||||
def test_valid_yaml(self, tmp_path):
|
||||
manifest = {"name": "cool-plugin", "version": "1.0.0"}
|
||||
(tmp_path / "plugin.yaml").write_text(yaml.dump(manifest))
|
||||
result = _read_manifest(tmp_path)
|
||||
assert result["name"] == "cool-plugin"
|
||||
assert result["version"] == "1.0.0"
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
result = _read_manifest(tmp_path)
|
||||
assert result == {}
|
||||
|
||||
def test_invalid_yaml_returns_empty_and_logs(self, tmp_path, caplog):
|
||||
(tmp_path / "plugin.yaml").write_text(": : : bad yaml [[[")
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins_cmd"):
|
||||
result = _read_manifest(tmp_path)
|
||||
assert result == {}
|
||||
assert any("Failed to read plugin.yaml" in r.message for r in caplog.records)
|
||||
|
||||
def test_empty_file_returns_empty(self, tmp_path):
|
||||
(tmp_path / "plugin.yaml").write_text("")
|
||||
result = _read_manifest(tmp_path)
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ── cmd_install tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdInstall:
|
||||
"""Test the install command."""
|
||||
|
||||
def test_install_requires_identifier(self):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
import argparse
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cmd_install("")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._resolve_git_url")
|
||||
def test_install_validates_identifier(self, mock_resolve):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
|
||||
mock_resolve.side_effect = ValueError("Invalid identifier")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_install("invalid")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._display_after_install")
|
||||
@patch("hermes_cli.plugins_cmd.shutil.move")
|
||||
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
@patch("hermes_cli.plugins_cmd._read_manifest")
|
||||
@patch("hermes_cli.plugins_cmd.subprocess.run")
|
||||
def test_install_rejects_manifest_name_pointing_at_plugins_root(
|
||||
self,
|
||||
mock_run,
|
||||
mock_read_manifest,
|
||||
mock_plugins_dir,
|
||||
mock_rmtree,
|
||||
mock_move,
|
||||
mock_display_after_install,
|
||||
tmp_path,
|
||||
):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugins_dir.mkdir()
|
||||
mock_plugins_dir.return_value = plugins_dir
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
mock_read_manifest.return_value = {"name": "."}
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_install("owner/repo", force=True)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert plugins_dir not in [call.args[0] for call in mock_rmtree.call_args_list]
|
||||
mock_move.assert_not_called()
|
||||
mock_display_after_install.assert_not_called()
|
||||
|
||||
|
||||
# ── cmd_update tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdUpdate:
|
||||
"""Test the update command."""
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
@patch("hermes_cli.plugins_cmd.subprocess.run")
|
||||
def test_update_git_pull_success(self, mock_run, mock_plugins_dir, mock_sanitize):
|
||||
from hermes_cli.plugins_cmd import cmd_update
|
||||
|
||||
mock_plugins_dir_val = MagicMock()
|
||||
mock_plugins_dir.return_value = mock_plugins_dir_val
|
||||
mock_target = MagicMock()
|
||||
mock_target.exists.return_value = True
|
||||
mock_target.__truediv__ = lambda self, x: MagicMock(
|
||||
exists=MagicMock(return_value=True)
|
||||
)
|
||||
mock_sanitize.return_value = mock_target
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="Updated", stderr="")
|
||||
|
||||
cmd_update("test-plugin")
|
||||
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
def test_update_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
|
||||
from hermes_cli.plugins_cmd import cmd_update
|
||||
|
||||
mock_plugins_dir_val = MagicMock()
|
||||
mock_plugins_dir_val.iterdir.return_value = []
|
||||
mock_plugins_dir.return_value = mock_plugins_dir_val
|
||||
mock_target = MagicMock()
|
||||
mock_target.exists.return_value = False
|
||||
mock_sanitize.return_value = mock_target
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_update("nonexistent-plugin")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
# ── cmd_remove tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdRemove:
|
||||
"""Test the remove command."""
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
@patch("hermes_cli.plugins_cmd.shutil.rmtree")
|
||||
def test_remove_deletes_plugin(self, mock_rmtree, mock_plugins_dir, mock_sanitize):
|
||||
from hermes_cli.plugins_cmd import cmd_remove
|
||||
|
||||
mock_plugins_dir.return_value = MagicMock()
|
||||
mock_target = MagicMock()
|
||||
mock_target.exists.return_value = True
|
||||
mock_sanitize.return_value = mock_target
|
||||
|
||||
cmd_remove("test-plugin")
|
||||
|
||||
mock_rmtree.assert_called_once_with(mock_target)
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
def test_remove_plugin_not_found(self, mock_plugins_dir, mock_sanitize):
|
||||
from hermes_cli.plugins_cmd import cmd_remove
|
||||
|
||||
mock_plugins_dir_val = MagicMock()
|
||||
mock_plugins_dir_val.iterdir.return_value = []
|
||||
mock_plugins_dir.return_value = mock_plugins_dir_val
|
||||
mock_target = MagicMock()
|
||||
mock_target.exists.return_value = False
|
||||
mock_sanitize.return_value = mock_target
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_remove("nonexistent-plugin")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
# ── cmd_list tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdList:
|
||||
"""Test the list command."""
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
def test_list_empty_plugins_dir(self, mock_plugins_dir):
|
||||
from hermes_cli.plugins_cmd import cmd_list
|
||||
|
||||
mock_plugins_dir_val = MagicMock()
|
||||
mock_plugins_dir_val.iterdir.return_value = []
|
||||
mock_plugins_dir.return_value = mock_plugins_dir_val
|
||||
|
||||
cmd_list()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
@patch("hermes_cli.plugins_cmd._read_manifest")
|
||||
def test_list_with_plugins(self, mock_read_manifest, mock_plugins_dir):
|
||||
from hermes_cli.plugins_cmd import cmd_list
|
||||
|
||||
mock_plugins_dir_val = MagicMock()
|
||||
mock_plugin_dir = MagicMock()
|
||||
mock_plugin_dir.name = "test-plugin"
|
||||
mock_plugin_dir.is_dir.return_value = True
|
||||
mock_plugin_dir.__truediv__ = lambda self, x: MagicMock(
|
||||
exists=MagicMock(return_value=False)
|
||||
)
|
||||
mock_plugins_dir_val.iterdir.return_value = [mock_plugin_dir]
|
||||
mock_plugins_dir.return_value = mock_plugins_dir_val
|
||||
mock_read_manifest.return_value = {"name": "test-plugin", "version": "1.0.0"}
|
||||
|
||||
cmd_list()
|
||||
|
||||
|
||||
# ── _copy_example_files tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCopyExampleFiles:
|
||||
"""Test example file copying."""
|
||||
|
||||
def test_copies_example_files(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _copy_example_files
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
console = MagicMock()
|
||||
|
||||
# Create example file
|
||||
example_file = tmp_path / "config.yaml.example"
|
||||
example_file.write_text("key: value")
|
||||
|
||||
_copy_example_files(tmp_path, console)
|
||||
|
||||
# Should have created the file
|
||||
assert (tmp_path / "config.yaml").exists()
|
||||
console.print.assert_called()
|
||||
|
||||
def test_skips_existing_files(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _copy_example_files
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
console = MagicMock()
|
||||
|
||||
# Create both example and real file
|
||||
example_file = tmp_path / "config.yaml.example"
|
||||
example_file.write_text("key: value")
|
||||
real_file = tmp_path / "config.yaml"
|
||||
real_file.write_text("existing: true")
|
||||
|
||||
_copy_example_files(tmp_path, console)
|
||||
|
||||
# Should NOT have overwritten
|
||||
assert real_file.read_text() == "existing: true"
|
||||
|
||||
def test_handles_copy_error_gracefully(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _copy_example_files
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
|
||||
# Create example file
|
||||
example_file = tmp_path / "config.yaml.example"
|
||||
example_file.write_text("key: value")
|
||||
|
||||
# Mock shutil.copy2 to raise an error
|
||||
with patch(
|
||||
"hermes_cli.plugins_cmd.shutil.copy2",
|
||||
side_effect=OSError("Permission denied"),
|
||||
):
|
||||
# Should not raise, just warn
|
||||
_copy_example_files(tmp_path, console)
|
||||
|
||||
# Should have printed a warning
|
||||
assert any("Warning" in str(c) for c in console.print.call_args_list)
|
||||
|
||||
|
||||
class TestPromptPluginEnvVars:
|
||||
"""Tests for _prompt_plugin_env_vars."""
|
||||
|
||||
def test_skips_when_no_requires_env(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
console = MagicMock()
|
||||
_prompt_plugin_env_vars({}, console)
|
||||
console.print.assert_not_called()
|
||||
|
||||
def test_skips_already_set_vars(self, monkeypatch):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
with patch("hermes_cli.config.get_env_value", return_value="already-set"):
|
||||
_prompt_plugin_env_vars({"requires_env": ["MY_KEY"]}, console)
|
||||
# No prompt should appear — all vars are set
|
||||
console.print.assert_not_called()
|
||||
|
||||
def test_prompts_for_missing_var_simple_format(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
manifest = {
|
||||
"name": "test_plugin",
|
||||
"requires_env": ["MY_API_KEY"],
|
||||
}
|
||||
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
||||
patch("builtins.input", return_value="sk-test-123"), \
|
||||
patch("hermes_cli.config.save_env_value") as mock_save:
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
|
||||
mock_save.assert_called_once_with("MY_API_KEY", "sk-test-123")
|
||||
|
||||
def test_prompts_for_missing_var_rich_format(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
manifest = {
|
||||
"name": "langfuse_tracing",
|
||||
"requires_env": [
|
||||
{
|
||||
"name": "LANGFUSE_PUBLIC_KEY",
|
||||
"description": "Public key",
|
||||
"url": "https://langfuse.com",
|
||||
"secret": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
||||
patch("builtins.input", return_value="pk-lf-123"), \
|
||||
patch("hermes_cli.config.save_env_value") as mock_save:
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
|
||||
mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123")
|
||||
# Should show url hint
|
||||
printed = " ".join(str(c) for c in console.print.call_args_list)
|
||||
assert "langfuse.com" in printed
|
||||
|
||||
def test_secret_uses_getpass(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"requires_env": [{"name": "SECRET_KEY", "secret": True}],
|
||||
}
|
||||
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
||||
patch("getpass.getpass", return_value="s3cret") as mock_gp, \
|
||||
patch("hermes_cli.config.save_env_value"):
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
|
||||
mock_gp.assert_called_once()
|
||||
|
||||
def test_empty_input_skips(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
manifest = {"name": "test", "requires_env": ["OPTIONAL_VAR"]}
|
||||
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
||||
patch("builtins.input", return_value=""), \
|
||||
patch("hermes_cli.config.save_env_value") as mock_save:
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_keyboard_interrupt_skips_gracefully(self):
|
||||
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
console = MagicMock()
|
||||
manifest = {"name": "test", "requires_env": ["KEY1", "KEY2"]}
|
||||
|
||||
with patch("hermes_cli.config.get_env_value", return_value=None), \
|
||||
patch("builtins.input", side_effect=KeyboardInterrupt), \
|
||||
patch("hermes_cli.config.save_env_value") as mock_save:
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
|
||||
# Should not crash, and not save anything
|
||||
mock_save.assert_not_called()
|
||||
1091
tests/hermes_cli/test_runtime_provider_resolution.py
Normal file
1091
tests/hermes_cli/test_runtime_provider_resolution.py
Normal file
File diff suppressed because it is too large
Load Diff
155
tests/hermes_cli/test_setup_model_selection.py
Normal file
155
tests/hermes_cli/test_setup_model_selection.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for _setup_provider_model_selection and the zai/kimi/minimax branch.
|
||||
|
||||
Regression test for the is_coding_plan NameError that crashed setup when
|
||||
selecting zai, kimi-coding, minimax, or minimax-cn providers.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_registry():
|
||||
"""Minimal PROVIDER_REGISTRY entries for tested providers."""
|
||||
class FakePConfig:
|
||||
def __init__(self, name, env_vars, base_url_env, inference_url):
|
||||
self.name = name
|
||||
self.api_key_env_vars = env_vars
|
||||
self.base_url_env_var = base_url_env
|
||||
self.inference_base_url = inference_url
|
||||
|
||||
return {
|
||||
"zai": FakePConfig("ZAI", ["ZAI_API_KEY"], "ZAI_BASE_URL", "https://api.zai.example"),
|
||||
"kimi-coding": FakePConfig("Kimi Coding", ["KIMI_API_KEY"], "KIMI_BASE_URL", "https://api.kimi.example"),
|
||||
"minimax": FakePConfig("MiniMax", ["MINIMAX_API_KEY"], "MINIMAX_BASE_URL", "https://api.minimax.example"),
|
||||
"minimax-cn": FakePConfig("MiniMax CN", ["MINIMAX_API_KEY"], "MINIMAX_CN_BASE_URL", "https://api.minimax-cn.example"),
|
||||
"opencode-zen": FakePConfig("OpenCode Zen", ["OPENCODE_ZEN_API_KEY"], "OPENCODE_ZEN_BASE_URL", "https://opencode.ai/zen/v1"),
|
||||
"opencode-go": FakePConfig("OpenCode Go", ["OPENCODE_GO_API_KEY"], "OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1"),
|
||||
}
|
||||
|
||||
|
||||
class TestSetupProviderModelSelection:
|
||||
"""Verify _setup_provider_model_selection works for all providers
|
||||
that previously hit the is_coding_plan NameError."""
|
||||
|
||||
@pytest.mark.parametrize("provider_id,expected_defaults", [
|
||||
("zai", ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"]),
|
||||
("kimi-coding", ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"]),
|
||||
("minimax", ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]),
|
||||
("minimax-cn", ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]),
|
||||
("opencode-zen", ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash"]),
|
||||
("opencode-go", ["glm-5", "kimi-k2.5", "minimax-m2.5", "minimax-m2.7"]),
|
||||
])
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=[])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_falls_back_to_default_models_without_crashing(
|
||||
self, mock_env, mock_fetch, provider_id, expected_defaults, mock_provider_registry
|
||||
):
|
||||
"""Previously this code path raised NameError: 'is_coding_plan'.
|
||||
Now it delegates to _setup_provider_model_selection which uses
|
||||
_DEFAULT_PROVIDER_MODELS -- no crash, correct model list."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
# Select "Keep current" (last item)
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id=provider_id,
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
# The offered model list should start with the default models
|
||||
offered = captured_choices["choices"]
|
||||
for model in expected_defaults:
|
||||
assert model in offered, f"{model} not in choices for {provider_id}"
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models")
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_live_models_used_when_available(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
"""When fetch_api_models returns results, those are used instead of defaults."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
live = ["live-model-1", "live-model-2"]
|
||||
mock_fetch.return_value = live
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id="zai",
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
offered = captured_choices["choices"]
|
||||
assert "live-model-1" in offered
|
||||
assert "live-model-2" in offered
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=[])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_custom_model_selection(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
"""Selecting 'Custom model' lets user type a model name."""
|
||||
from hermes_cli.setup import _setup_provider_model_selection, _DEFAULT_PROVIDER_MODELS
|
||||
|
||||
defaults = _DEFAULT_PROVIDER_MODELS["zai"]
|
||||
custom_model_idx = len(defaults) # "Custom model" is right after defaults
|
||||
|
||||
config = {"model": {}}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
return custom_model_idx
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config=config,
|
||||
provider_id="zai",
|
||||
current_model="some-model",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: "my-custom-model",
|
||||
)
|
||||
|
||||
assert config["model"]["default"] == "my-custom-model"
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.7"])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_opencode_live_models_are_normalized_for_selection(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id="opencode-go",
|
||||
current_model="opencode-go/kimi-k2.5",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
offered = captured_choices["choices"]
|
||||
assert "kimi-k2.5" in offered
|
||||
assert "minimax-m2.7" in offered
|
||||
assert all("opencode-go/" not in choice for choice in offered)
|
||||
@@ -15,7 +15,7 @@ def test_version_string_no_v_prefix():
|
||||
assert not __version__.startswith("v"), f"__version__ should not start with 'v', got {__version__!r}"
|
||||
|
||||
|
||||
def test_check_for_updates_uses_cache(tmp_path):
|
||||
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
"""When cache is fresh, check_for_updates should return cached value without calling git."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
|
||||
@@ -27,15 +27,15 @@ def test_check_for_updates_uses_cache(tmp_path):
|
||||
cache_file = tmp_path / ".update_check"
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
|
||||
|
||||
with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)):
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = check_for_updates()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = check_for_updates()
|
||||
|
||||
assert result == 3
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_check_for_updates_expired_cache(tmp_path):
|
||||
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
||||
"""When cache is expired, check_for_updates should call git fetch."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
|
||||
@@ -49,15 +49,15 @@ def test_check_for_updates_expired_cache(tmp_path):
|
||||
|
||||
mock_result = MagicMock(returncode=0, stdout="5\n")
|
||||
|
||||
with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)):
|
||||
with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = check_for_updates()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = check_for_updates()
|
||||
|
||||
assert result == 5
|
||||
assert mock_run.call_count == 2 # git fetch + git rev-list
|
||||
|
||||
|
||||
def test_check_for_updates_no_git_dir(tmp_path):
|
||||
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
||||
"""Returns None when .git directory doesn't exist anywhere."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
@@ -66,19 +66,15 @@ def test_check_for_updates_no_git_dir(tmp_path):
|
||||
fake_banner.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_banner.touch()
|
||||
|
||||
original = banner.__file__
|
||||
try:
|
||||
banner.__file__ = str(fake_banner)
|
||||
with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)):
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = banner.check_for_updates()
|
||||
assert result is None
|
||||
mock_run.assert_not_called()
|
||||
finally:
|
||||
banner.__file__ = original
|
||||
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = banner.check_for_updates()
|
||||
assert result is None
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_check_for_updates_fallback_to_project_root():
|
||||
def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
|
||||
"""Dev install: falls back to Path(__file__).parent.parent when HERMES_HOME has no git repo."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
@@ -87,14 +83,12 @@ def test_check_for_updates_fallback_to_project_root():
|
||||
pytest.skip("Not running from a git checkout")
|
||||
|
||||
# Point HERMES_HOME at a temp dir with no hermes-agent/.git
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with patch("hermes_cli.banner.os.getenv", return_value=td):
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="0\n")
|
||||
result = banner.check_for_updates()
|
||||
# Should have fallen back to project root and run git commands
|
||||
assert mock_run.call_count >= 1
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="0\n")
|
||||
result = banner.check_for_updates()
|
||||
# Should have fallen back to project root and run git commands
|
||||
assert mock_run.call_count >= 1
|
||||
|
||||
|
||||
def test_prefetch_non_blocking():
|
||||
|
||||
Reference in New Issue
Block a user