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:
parent
f656dfcb32
commit
0c392e7a87
26 changed files with 2472 additions and 122 deletions
|
|
@ -248,6 +248,31 @@ class TestVisionClientFallback:
|
|||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||
assert model == "claude-haiku-4-5-20251001"
|
||||
|
||||
def test_resolve_provider_client_copilot_uses_runtime_credentials(self, monkeypatch):
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
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("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
client, model = resolve_provider_client("copilot", model="gpt-5.4")
|
||||
|
||||
assert client is not None
|
||||
assert model == "gpt-5.4"
|
||||
call_kwargs = mock_openai.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "gh-cli-token"
|
||||
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
|
||||
assert call_kwargs["default_headers"]["Editor-Version"]
|
||||
|
||||
def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
||||
with (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.models import (
|
||||
copilot_model_api_mode,
|
||||
fetch_github_model_catalog,
|
||||
curated_models_for_provider,
|
||||
fetch_api_models,
|
||||
github_model_reasoning_efforts,
|
||||
normalize_copilot_model_id,
|
||||
normalize_provider,
|
||||
parse_model_input,
|
||||
probe_api_models,
|
||||
|
|
@ -116,6 +120,7 @@ class TestNormalizeProvider:
|
|||
assert normalize_provider("glm") == "zai"
|
||||
assert normalize_provider("kimi") == "kimi-coding"
|
||||
assert normalize_provider("moonshot") == "kimi-coding"
|
||||
assert normalize_provider("github-copilot") == "copilot"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert normalize_provider("OpenRouter") == "openrouter"
|
||||
|
|
@ -125,6 +130,8 @@ class TestProviderLabel:
|
|||
def test_known_labels_and_auto(self):
|
||||
assert provider_label("anthropic") == "Anthropic"
|
||||
assert provider_label("kimi") == "Kimi / Moonshot"
|
||||
assert provider_label("copilot") == "GitHub Copilot"
|
||||
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
|
||||
assert provider_label("auto") == "Auto"
|
||||
|
||||
def test_unknown_provider_preserves_original_name(self):
|
||||
|
|
@ -145,6 +152,24 @@ class TestProviderModelIds:
|
|||
def test_zai_returns_glm_models(self):
|
||||
assert "glm-5" in provider_model_ids("zai")
|
||||
|
||||
def test_copilot_prefers_live_catalog(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
||||
assert provider_model_ids("copilot") == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
|
||||
def test_copilot_acp_reuses_copilot_catalog(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
||||
assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
|
||||
def test_copilot_acp_falls_back_to_copilot_defaults(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", side_effect=Exception("no token")), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=None):
|
||||
ids = provider_model_ids("copilot-acp")
|
||||
|
||||
assert "gpt-5.4" in ids
|
||||
assert "copilot-acp" not in ids
|
||||
|
||||
|
||||
# -- fetch_api_models --------------------------------------------------------
|
||||
|
||||
|
|
@ -183,6 +208,82 @@ class TestFetchApiModels:
|
|||
assert probe["resolved_base_url"] == "http://localhost:8000/v1"
|
||||
assert probe["used_fallback"] is True
|
||||
|
||||
def test_probe_api_models_uses_copilot_catalog(self):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "claude-sonnet-4.6", "model_picker_enabled": true, "supported_endpoints": ["/chat/completions"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'
|
||||
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()) as mock_urlopen:
|
||||
probe = probe_api_models("gh-token", "https://api.githubcopilot.com")
|
||||
|
||||
assert mock_urlopen.call_args[0][0].full_url == "https://api.githubcopilot.com/models"
|
||||
assert probe["models"] == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
assert probe["resolved_base_url"] == "https://api.githubcopilot.com"
|
||||
assert probe["used_fallback"] is False
|
||||
|
||||
def test_fetch_github_model_catalog_filters_non_chat_models(self):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'
|
||||
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
catalog = fetch_github_model_catalog("gh-token")
|
||||
|
||||
assert catalog is not None
|
||||
assert [item["id"] for item in catalog] == ["gpt-5.4"]
|
||||
|
||||
|
||||
class TestGithubReasoningEfforts:
|
||||
def test_gpt5_supports_minimal_to_high(self):
|
||||
catalog = [{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
}]
|
||||
assert github_model_reasoning_efforts("gpt-5.4", catalog=catalog) == [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]
|
||||
|
||||
def test_legacy_catalog_reasoning_still_supported(self):
|
||||
catalog = [{"id": "openai/o3", "capabilities": ["reasoning"]}]
|
||||
assert github_model_reasoning_efforts("openai/o3", catalog=catalog) == [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]
|
||||
|
||||
def test_non_reasoning_model_returns_empty(self):
|
||||
catalog = [{"id": "gpt-4.1", "capabilities": {"type": "chat", "supports": {}}}]
|
||||
assert github_model_reasoning_efforts("gpt-4.1", catalog=catalog) == []
|
||||
|
||||
|
||||
class TestCopilotNormalization:
|
||||
def test_normalize_old_github_models_slug(self):
|
||||
catalog = [{"id": "gpt-4.1"}, {"id": "gpt-5.4"}]
|
||||
assert normalize_copilot_model_id("openai/gpt-4.1-mini", catalog=catalog) == "gpt-4.1"
|
||||
|
||||
def test_copilot_api_mode_prefers_responses(self):
|
||||
catalog = [{
|
||||
"id": "gpt-5.4",
|
||||
"supported_endpoints": ["/responses"],
|
||||
"capabilities": {"type": "chat"},
|
||||
}]
|
||||
assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"
|
||||
|
||||
|
||||
# -- validate — format checks -----------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ def _clear_provider_env(monkeypatch):
|
|||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
|
|
@ -231,6 +233,152 @@ def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_pa
|
|||
assert env.get("AUXILIARY_VISION_MODEL") == "gpt-4o-mini"
|
||||
|
||||
|
||||
def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
config = load_config()
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
if question == "Select your inference provider:":
|
||||
assert choices[14] == "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"
|
||||
return 14
|
||||
if question == "Select default model:":
|
||||
assert "gpt-4.1" in choices
|
||||
assert "gpt-5.4" in choices
|
||||
return choices.index("gpt-5.4")
|
||||
if question == "Select reasoning effort:":
|
||||
assert "low" in choices
|
||||
assert "high" in choices
|
||||
return choices.index("high")
|
||||
if question == "Configure vision:":
|
||||
return len(choices) - 1
|
||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
||||
if tts_idx is not None:
|
||||
return tts_idx
|
||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||
|
||||
def fake_prompt(message, *args, **kwargs):
|
||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
||||
|
||||
def fake_get_auth_status(provider_id):
|
||||
if provider_id == "copilot":
|
||||
return {"logged_in": True}
|
||||
return {"logged_in": False}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", fake_get_auth_status)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
lambda provider_id: {
|
||||
"provider": provider_id,
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key: [
|
||||
{
|
||||
"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"],
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
env = _read_env(tmp_path)
|
||||
reloaded = load_config()
|
||||
|
||||
assert env.get("GITHUB_TOKEN") is None
|
||||
assert reloaded["model"]["provider"] == "copilot"
|
||||
assert reloaded["model"]["base_url"] == "https://api.githubcopilot.com"
|
||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
||||
assert reloaded["model"]["api_mode"] == "codex_responses"
|
||||
assert reloaded["agent"]["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_setup_copilot_acp_uses_model_picker_and_saves_provider(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
config = load_config()
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
if question == "Select your inference provider:":
|
||||
assert choices[15] == "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"
|
||||
return 15
|
||||
if question == "Select default model:":
|
||||
assert "gpt-4.1" in choices
|
||||
assert "gpt-5.4" in choices
|
||||
return choices.index("gpt-5.4")
|
||||
if question == "Configure vision:":
|
||||
return len(choices) - 1
|
||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
||||
if tts_idx is not None:
|
||||
return tts_idx
|
||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||
|
||||
def fake_prompt(message, *args, **kwargs):
|
||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda provider_id: {"logged_in": provider_id == "copilot-acp"})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
lambda provider_id: {
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key: [
|
||||
{
|
||||
"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"],
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
reloaded = load_config()
|
||||
|
||||
assert reloaded["model"]["provider"] == "copilot-acp"
|
||||
assert reloaded["model"]["base_url"] == "acp://copilot"
|
||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
||||
assert reloaded["model"]["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch):
|
||||
"""Switching from custom to Codex should clear custom endpoint overrides."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ from hermes_cli.auth import (
|
|||
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,
|
||||
)
|
||||
|
||||
|
|
@ -33,6 +36,8 @@ 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"),
|
||||
("zai", "Z.AI / GLM", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
|
|
@ -52,6 +57,11 @@ class TestProviderRegistry:
|
|||
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 == ("GITHUB_TOKEN", "GH_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",)
|
||||
|
|
@ -78,6 +88,8 @@ class TestProviderRegistry:
|
|||
assert pconfig.base_url_env_var == "KILOCODE_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/v1"
|
||||
|
|
@ -105,8 +117,9 @@ PROVIDER_ENV_VARS = (
|
|||
"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",
|
||||
"OPENAI_BASE_URL",
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -176,6 +189,16 @@ class TestResolveProvider:
|
|||
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_unknown_provider_raises(self):
|
||||
with pytest.raises(AuthError):
|
||||
resolve_provider("nonexistent-provider-xyz")
|
||||
|
|
@ -218,6 +241,10 @@ class TestResolveProvider:
|
|||
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")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Key Provider Status tests
|
||||
|
|
@ -251,12 +278,41 @@ class TestApiKeyProviderStatus:
|
|||
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.auth._try_gh_cli_token", lambda: "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
|
||||
|
|
@ -276,6 +332,61 @@ class TestResolveApiKeyProviderCredentials:
|
|||
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.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
assert creds["provider"] == "copilot"
|
||||
assert creds["api_key"] == "gh-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")
|
||||
|
|
@ -403,6 +514,53 @@ class TestRuntimeProviderResolution:
|
|||
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.auth._try_gh_cli_token", lambda: "gh-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"] == "gh-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.auth._try_gh_cli_token", lambda: "gh-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
|
||||
|
|
@ -430,6 +588,16 @@ class TestHasAnyProviderConfigured:
|
|||
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.auth._try_gh_cli_token", lambda: "gh-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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kimi Code auto-detection tests
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -631,6 +631,28 @@ class TestBuildApiKwargs:
|
|||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
|
||||
|
||||
def test_reasoning_sent_for_copilot_gpt5(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-5.4"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}
|
||||
|
||||
def test_reasoning_xhigh_normalized_for_copilot(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-5.4"
|
||||
agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"] == {"effort": "high"}
|
||||
|
||||
def test_reasoning_omitted_for_non_reasoning_copilot_model(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-4.1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "reasoning" not in kwargs.get("extra_body", {})
|
||||
|
||||
def test_max_tokens_injected(self, agent):
|
||||
agent.max_tokens = 4096
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
|
@ -2172,6 +2194,41 @@ class TestFallbackAnthropicProvider:
|
|||
assert agent.client is mock_client
|
||||
|
||||
|
||||
def test_aiagent_uses_copilot_acp_client():
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI") as mock_openai,
|
||||
patch("agent.copilot_acp_client.CopilotACPClient") as mock_acp_client,
|
||||
):
|
||||
acp_client = MagicMock()
|
||||
mock_acp_client.return_value = acp_client
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="copilot-acp",
|
||||
base_url="acp://copilot",
|
||||
provider="copilot-acp",
|
||||
acp_command="/usr/local/bin/copilot",
|
||||
acp_args=["--acp", "--stdio"],
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
assert agent.client is acp_client
|
||||
mock_openai.assert_not_called()
|
||||
mock_acp_client.assert_called_once()
|
||||
assert mock_acp_client.call_args.kwargs["base_url"] == "acp://copilot"
|
||||
assert mock_acp_client.call_args.kwargs["api_key"] == "copilot-acp"
|
||||
assert mock_acp_client.call_args.kwargs["command"] == "/usr/local/bin/copilot"
|
||||
assert mock_acp_client.call_args.kwargs["args"] == ["--acp", "--stdio"]
|
||||
|
||||
|
||||
def test_is_openai_client_closed_honors_custom_client_flag():
|
||||
assert AIAgent._is_openai_client_closed(SimpleNamespace(is_closed=True)) is True
|
||||
assert AIAgent._is_openai_client_closed(SimpleNamespace(is_closed=False)) is False
|
||||
|
||||
|
||||
class TestAnthropicBaseUrlPassthrough:
|
||||
"""Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies."""
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,27 @@ def _build_agent(monkeypatch):
|
|||
return agent
|
||||
|
||||
|
||||
def _build_copilot_agent(monkeypatch, *, model="gpt-5.4"):
|
||||
_patch_agent_bootstrap(monkeypatch)
|
||||
|
||||
agent = run_agent.AIAgent(
|
||||
model=model,
|
||||
provider="copilot",
|
||||
api_mode="codex_responses",
|
||||
base_url="https://api.githubcopilot.com",
|
||||
api_key="gh-token",
|
||||
quiet_mode=True,
|
||||
max_iterations=4,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
return agent
|
||||
|
||||
|
||||
def _codex_message_response(text: str):
|
||||
return SimpleNamespace(
|
||||
output=[
|
||||
|
|
@ -244,6 +265,28 @@ def test_build_api_kwargs_codex(monkeypatch):
|
|||
assert "extra_body" not in kwargs
|
||||
|
||||
|
||||
def test_build_api_kwargs_copilot_responses_omits_openai_only_fields(monkeypatch):
|
||||
agent = _build_copilot_agent(monkeypatch)
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert kwargs["model"] == "gpt-5.4"
|
||||
assert kwargs["store"] is False
|
||||
assert kwargs["tool_choice"] == "auto"
|
||||
assert kwargs["parallel_tool_calls"] is True
|
||||
assert kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert "prompt_cache_key" not in kwargs
|
||||
assert "include" not in kwargs
|
||||
|
||||
|
||||
def test_build_api_kwargs_copilot_responses_omits_reasoning_for_non_reasoning_model(monkeypatch):
|
||||
agent = _build_copilot_agent(monkeypatch, model="gpt-4.1")
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert "reasoning" not in kwargs
|
||||
assert "include" not in kwargs
|
||||
assert "prompt_cache_key" not in kwargs
|
||||
|
||||
|
||||
def test_run_codex_stream_retries_when_completed_event_missing(monkeypatch):
|
||||
agent = _build_agent(monkeypatch)
|
||||
calls = {"stream": 0}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue