Add first-class GitHub Copilot and Copilot ACP provider support across model selection, runtime provider resolution, CLI sessions, delegated subagents, cron jobs, and the Telegram gateway. This also normalizes Copilot model catalogs and API modes, introduces a Copilot ACP OpenAI-compatible shim, and fixes service-mode auth by resolving Homebrew-installed gh binaries under launchd. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
213 lines
8.6 KiB
Python
213 lines
8.6 KiB
Python
"""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"
|