feat: integrate GitHub Copilot providers across Hermes

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>
This commit is contained in:
max 2026-03-17 23:40:22 -07:00
parent f656dfcb32
commit 0c392e7a87
26 changed files with 2472 additions and 122 deletions

View file

@ -27,6 +27,8 @@ def config_home(tmp_path, monkeypatch):
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)
@ -97,3 +99,114 @@ class TestProviderPersistsAfterModelSave:
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"