From a654bc04f7045da55124544af43043e26d38e012 Mon Sep 17 00:00:00 2001 From: darya <137614867+cutepawss@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:19:05 +0300 Subject: [PATCH 1/8] fix(file_tools): include pagination args in repeated search key --- tests/tools/test_read_loop_detection.py | 8 ++++++++ tools/file_tools.py | 12 +++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_read_loop_detection.py b/tests/tools/test_read_loop_detection.py index a7c01170fc..78f7949c88 100644 --- a/tests/tools/test_read_loop_detection.py +++ b/tests/tools/test_read_loop_detection.py @@ -441,6 +441,14 @@ class TestSearchLoopDetection(unittest.TestCase): self.assertNotIn("_warning", result) self.assertNotIn("error", result) + @patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops()) + def test_pagination_offset_does_not_count_as_repeat(self, _mock_ops): + """Paginating truncated results should not be blocked as a repeat search.""" + for offset in (0, 50, 100, 150): + result = json.loads(search_tool("def main", task_id="t1", offset=offset, limit=50)) + self.assertNotIn("_warning", result) + self.assertNotIn("error", result) + @patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops()) def test_read_between_searches_resets_consecutive(self, _mock_ops): """A read_file call between searches resets search consecutive counter.""" diff --git a/tools/file_tools.py b/tools/file_tools.py index 03470c3758..d34c59f492 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -337,7 +337,17 @@ def search_tool(pattern: str, target: str = "content", path: str = ".", """Search for content or files.""" try: # Track searches to detect *consecutive* repeated search loops. - search_key = ("search", pattern, target, str(path), file_glob or "") + # Include pagination args so users can page through truncated + # results without tripping the repeated-search guard. + search_key = ( + "search", + pattern, + target, + str(path), + file_glob or "", + limit, + offset, + ) with _read_tracker_lock: task_data = _read_tracker.setdefault(task_id, { "last_key": None, "consecutive": 0, "read_history": set(), From d35d923c768025f97d8b92914d17330fe05c487d Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 16:06:49 -0700 Subject: [PATCH 2/8] feat: cron agents can suppress delivery with [SILENT] response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cron job prompt now includes guidance that the agent can respond with [SILENT] when it has nothing new or noteworthy to report. The scheduler checks for this marker and skips delivery, while still saving output to disk for audit. Failed jobs always deliver regardless. This replaces the notify parameter approach from PR #1807 with a simpler always-on design — the model is smart enough to decide when there's nothing worth reporting without needing a per-job flag. --- cron/scheduler.py | 27 +++++++++- tests/cron/test_scheduler.py | 96 +++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index a3636883f0..2060bf2fbe 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -37,6 +37,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import get_due_jobs, mark_job_run, save_job_output +# Sentinel: when a cron agent has nothing new to report, it can start its +# response with this marker to suppress delivery. Output is still saved +# locally for audit. +SILENT_MARKER = "[SILENT]" + # Resolve Hermes home directory (respects HERMES_HOME override) _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) @@ -180,6 +185,17 @@ def _build_job_prompt(job: dict) -> str: """Build the effective prompt for a cron job, optionally loading one or more skills first.""" prompt = job.get("prompt", "") skills = job.get("skills") + + # Always prepend [SILENT] guidance so the cron agent can suppress + # delivery when it has nothing new or noteworthy to report. + silent_hint = ( + "[SYSTEM: If you have nothing new or noteworthy to report, respond " + "with exactly \"[SILENT]\" (optionally followed by a brief internal " + "note). This suppresses delivery to the user while still saving " + "output locally. Only use [SILENT] when there are genuinely no " + "changes worth reporting.]\n\n" + ) + prompt = silent_hint + prompt if skills is None: legacy = job.get("skill") skills = [legacy] if legacy else [] @@ -480,9 +496,16 @@ def tick(verbose: bool = True) -> int: if verbose: logger.info("Output saved to: %s", output_file) - # Deliver the final response to the origin/target chat + # Deliver the final response to the origin/target chat. + # If the agent responded with [SILENT], skip delivery (but + # output is already saved above). Failed jobs always deliver. deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" - if deliver_content: + should_deliver = bool(deliver_content) + if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER): + logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) + should_deliver = False + + if should_deliver: try: _deliver_result(job, deliver_content) except Exception as de: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index ad256714a0..6c3926337f 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch, MagicMock import pytest -from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job +from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job, SILENT_MARKER class TestResolveOrigin: @@ -449,3 +449,97 @@ class TestRunJobSkillBacked: assert "Instructions for blogwatcher." in prompt_arg assert "Instructions for find-nearby." in prompt_arg assert "Combine the results." in prompt_arg + + +class TestSilentDelivery: + """Verify that [SILENT] responses suppress delivery while still saving output.""" + + def _make_job(self): + return { + "id": "monitor-job", + "name": "monitor", + "deliver": "origin", + "origin": {"platform": "telegram", "chat_id": "123"}, + } + + def test_normal_response_delivers(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_called_once() + + def test_silent_response_suppresses_delivery(self, caplog): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + with caplog.at_level(logging.INFO, logger="cron.scheduler"): + tick(verbose=False) + deliver_mock.assert_not_called() + assert any(SILENT_MARKER in r.message for r in caplog.records) + + def test_silent_with_note_suppresses_delivery(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT] No changes detected", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_not_called() + + def test_silent_is_case_insensitive(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_not_called() + + def test_failed_job_always_delivers(self): + """Failed jobs deliver regardless of [SILENT] in output.""" + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(False, "# output", "", "some error")), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_called_once() + + def test_output_saved_even_when_delivery_suppressed(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# full output", "[SILENT]", None)), \ + patch("cron.scheduler.save_job_output") as save_mock, \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + save_mock.return_value = "/tmp/out.md" + from cron.scheduler import tick + tick(verbose=False) + save_mock.assert_called_once_with("monitor-job", "# full output") + deliver_mock.assert_not_called() + + +class TestBuildJobPromptSilentHint: + """Verify _build_job_prompt always injects [SILENT] guidance.""" + + def test_hint_always_present(self): + from cron.scheduler import _build_job_prompt + job = {"prompt": "Check for updates"} + result = _build_job_prompt(job) + assert "[SILENT]" in result + assert "Check for updates" in result + + def test_hint_present_even_without_prompt(self): + from cron.scheduler import _build_job_prompt + job = {"prompt": ""} + result = _build_job_prompt(job) + assert "[SILENT]" in result From 72bfa115a03ad028a50134eb81ced0522fed6b43 Mon Sep 17 00:00:00 2001 From: charliekerfoot Date: Tue, 17 Mar 2026 18:27:00 -0400 Subject: [PATCH 3/8] fix(discord): removebugged follow up messages from discord slash commands --- gateway/platforms/discord.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 43b48b3dbb..d7a9965b43 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1364,16 +1364,17 @@ class DiscordAdapter(BasePlatformAdapter): self, interaction: discord.Interaction, command_text: str, - followup_msg: str = "Done~", + followup_msg: str | None = None, ) -> None: """Common handler for simple slash commands that dispatch a command string.""" await interaction.response.defer(ephemeral=True) event = self._build_slash_event(interaction, command_text) await self.handle_message(event) - try: - await interaction.followup.send(followup_msg, ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + if followup_msg: + try: + await interaction.followup.send(followup_msg, ephemeral=True) + except Exception as e: + logger.debug("Discord followup failed: %s", e) def _register_slash_commands(self) -> None: """Register Discord slash commands on the command tree.""" @@ -1388,12 +1389,6 @@ class DiscordAdapter(BasePlatformAdapter): await interaction.response.defer() event = self._build_slash_event(interaction, question) await self.handle_message(event) - # The response is sent via the normal send() flow - # Send a followup to close the interaction if needed - try: - await interaction.followup.send("Processing complete~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) @tree.command(name="new", description="Start a new conversation") async def slash_new(interaction: discord.Interaction): @@ -1414,10 +1409,6 @@ class DiscordAdapter(BasePlatformAdapter): await interaction.response.defer(ephemeral=True) event = self._build_slash_event(interaction, f"/reasoning {effort}".strip()) await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) @tree.command(name="personality", description="Set a personality") @discord.app_commands.describe(name="Personality name. Leave empty to list available.") @@ -1493,10 +1484,6 @@ class DiscordAdapter(BasePlatformAdapter): await interaction.response.defer(ephemeral=True) event = self._build_slash_event(interaction, f"/voice {mode}".strip()) await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) @tree.command(name="update", description="Update Hermes Agent to the latest version") async def slash_update(interaction: discord.Interaction): From 1bee519a6f1989cf7bb0635c1325a5c1b68ec395 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 16:25:09 -0700 Subject: [PATCH 4/8] fix(discord): remove redundant /ask slash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ask was just 'send a message to the bot' via the slash command menu — completely redundant since Discord bots already listen to channel messages. Removed as part of salvaging PR #1827. --- gateway/platforms/discord.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index d7a9965b43..af36d56824 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1383,13 +1383,6 @@ class DiscordAdapter(BasePlatformAdapter): tree = self._client.tree - @tree.command(name="ask", description="Ask Hermes a question") - @discord.app_commands.describe(question="Your question for Hermes") - async def slash_ask(interaction: discord.Interaction, question: str): - await interaction.response.defer() - event = self._build_slash_event(interaction, question) - await self.handle_message(event) - @tree.command(name="new", description="Start a new conversation") async def slash_new(interaction: discord.Interaction): await self._run_simple_slash(interaction, "/reset", "New conversation started~") From 45bad9771d84414f08820f82284dac8750f0cd27 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 16:31:01 -0700 Subject: [PATCH 5/8] fix(context_compressor): replace print() calls with logger Replaces all remaining print() calls in compress() with logger.info() and logger.warning() for consistency with the rest of the module. Inspired by PR #1822. --- agent/context_compressor.py | 39 ++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 01c0d511ca..8ff43da507 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -275,7 +275,11 @@ Write only the summary body. Do not include any preamble or prefix; the system w n_messages = len(messages) if n_messages <= self.protect_first_n + self.protect_last_n + 1: if not self.quiet_mode: - print(f"⚠️ Cannot compress: only {n_messages} messages (need > {self.protect_first_n + self.protect_last_n + 1})") + logger.warning( + "Cannot compress: only %d messages (need > %d)", + n_messages, + self.protect_first_n + self.protect_last_n + 1, + ) return messages compress_start = self.protect_first_n @@ -293,11 +297,23 @@ Write only the summary body. Do not include any preamble or prefix; the system w display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages) if not self.quiet_mode: - print(f"\n📦 Context compression triggered ({display_tokens:,} tokens ≥ {self.threshold_tokens:,} threshold)") - print(f" 📊 Model context limit: {self.context_length:,} tokens ({self.threshold_percent*100:.0f}% = {self.threshold_tokens:,})") - - if not self.quiet_mode: - print(f" 🗜️ Summarizing turns {compress_start+1}-{compress_end} ({len(turns_to_summarize)} turns)") + logger.info( + "Context compression triggered (%d tokens >= %d threshold)", + display_tokens, + self.threshold_tokens, + ) + logger.info( + "Model context limit: %d tokens (%.0f%% = %d)", + self.context_length, + self.threshold_percent * 100, + self.threshold_tokens, + ) + logger.info( + "Summarizing turns %d-%d (%d turns)", + compress_start + 1, + compress_end, + len(turns_to_summarize), + ) summary = self._generate_summary(turns_to_summarize) @@ -337,7 +353,7 @@ Write only the summary body. Do not include any preamble or prefix; the system w compressed.append({"role": summary_role, "content": summary}) else: if not self.quiet_mode: - print(" ⚠️ No summary model available — middle turns dropped without summary") + logger.warning("No summary model available — middle turns dropped without summary") for i in range(compress_end, n_messages): msg = messages[i].copy() @@ -354,7 +370,12 @@ Write only the summary body. Do not include any preamble or prefix; the system w if not self.quiet_mode: new_estimate = estimate_messages_tokens_rough(compressed) saved_estimate = display_tokens - new_estimate - print(f" ✅ Compressed: {n_messages} → {len(compressed)} messages (~{saved_estimate:,} tokens saved)") - print(f" 💡 Compression #{self.compression_count} complete") + logger.info( + "Compressed: %d -> %d messages (~%d tokens saved)", + n_messages, + len(compressed), + saved_estimate, + ) + logger.info("Compression #%d complete", self.compression_count) return compressed From 7ce374d3b9dd0c7b580592fec9292e2cb941a205 Mon Sep 17 00:00:00 2001 From: silentconsensus Date: Tue, 17 Mar 2026 13:25:20 -0700 Subject: [PATCH 6/8] Improve gateway error handling for 429 usage limits and 500 context overflow - Distinguish plan usage limits (429 with usage_limit_reached) from transient rate limits - Show approximate reset time in hours for plan limits - Treat HTTP 500 with large sessions as context overflow (same as 400) - Move history length check earlier for reuse across status codes --- gateway/run.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 59b172af03..ea9f2a2838 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2122,23 +2122,41 @@ class GatewayRunner: error_detail = str(e)[:300] if str(e) else "no details available" status_hint = "" status_code = getattr(e, "status_code", None) + _hist_len = len(history) if 'history' in locals() else 0 if status_code == 401: status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials." elif status_code == 429: - status_hint = " You are being rate-limited. Please wait a moment and try again." + # Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit + _err_body = getattr(e, "response", None) + _err_json = {} + try: + if _err_body is not None: + _err_json = _err_body.json().get("error", {}) + except Exception: + pass + if _err_json.get("type") == "usage_limit_reached": + _resets_in = _err_json.get("resets_in_seconds") + if _resets_in and _resets_in > 0: + import math + _hours = math.ceil(_resets_in / 3600) + status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h." + else: + status_hint = " Your plan's usage limit has been reached. Please wait until it resets." + else: + status_hint = " You are being rate-limited. Please wait a moment and try again." elif status_code == 529: status_hint = " The API is temporarily overloaded. Please try again shortly." - elif status_code == 400: - # 400 with a large session is almost always a context overflow. - # Give specific guidance instead of a generic error. (#1630) - _hist_len = len(history) if 'history' in locals() else 0 + elif status_code in (400, 500): + # 400 with a large session is context overflow. + # 500 with a large session often means the payload is too large + # for the API to process — treat it the same way. if _hist_len > 50: return ( "⚠️ Session too large for the model's context window.\n" "Use /compact to compress the conversation, or " "/reset to start fresh." ) - else: + elif status_code == 400: status_hint = " The request was rejected by the API." return ( f"Sorry, I encountered an error ({error_type}).\n" From 0fab46f65ca219420b2eb9303518b7e5dda8f369 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 12:18:53 -0700 Subject: [PATCH 7/8] fix: allow agent-created skills with caution-level findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-created skills were using the same policy as community hub installs, blocking any skill with medium/high severity findings (e.g. docker pull, pip install, git clone). This meant the agent couldn't create skills that reference Docker or other common tools. Changed agent-created policy from (allow, block, block) to (allow, allow, block) — matching the trusted policy. Caution-level findings (medium/high severity) are now allowed through, while dangerous findings (critical severity like exfiltration, prompt injection, reverse shells) remain blocked. Added 4 tests covering the agent-created policy: safe allowed, caution allowed, dangerous blocked, force override. --- tests/tools/test_skills_guard.py | 28 ++++++++++++++++++++++++++++ tools/skills_guard.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index 7bcf55e813..d67057776a 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -154,6 +154,34 @@ class TestShouldAllowInstall: assert allowed is True assert "Force-installed" in reason + # -- agent-created policy -- + + def test_safe_agent_created_allowed(self): + allowed, _ = should_allow_install(self._result("agent-created", "safe")) + assert allowed is True + + def test_caution_agent_created_allowed(self): + """Agent-created skills with caution verdict (e.g. docker refs) should pass.""" + f = [Finding("docker_pull", "medium", "supply_chain", "SKILL.md", 1, "docker pull img", "pulls Docker image")] + allowed, reason = should_allow_install(self._result("agent-created", "caution", f)) + assert allowed is True + assert "agent-created" in reason + + def test_dangerous_agent_created_blocked(self): + """Agent-created skills with dangerous verdict (critical findings) stay blocked.""" + f = [Finding("env_exfil_curl", "critical", "exfiltration", "SKILL.md", 1, "curl $TOKEN", "exfiltration")] + allowed, reason = should_allow_install(self._result("agent-created", "dangerous", f)) + assert allowed is False + assert "Blocked" in reason + + def test_force_overrides_dangerous_for_agent_created(self): + f = [Finding("x", "critical", "c", "f", 1, "m", "d")] + allowed, reason = should_allow_install( + self._result("agent-created", "dangerous", f), force=True + ) + assert allowed is True + assert "Force-installed" in reason + # --------------------------------------------------------------------------- # scan_file — pattern detection diff --git a/tools/skills_guard.py b/tools/skills_guard.py index df62edbe6c..3702a2b69b 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -43,7 +43,7 @@ INSTALL_POLICY = { "builtin": ("allow", "allow", "allow"), "trusted": ("allow", "allow", "block"), "community": ("allow", "block", "block"), - "agent-created": ("allow", "block", "block"), + "agent-created": ("allow", "allow", "block"), } VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2} From 0c392e7a8743f8c29d5c7a2332e9a67e457a1364 Mon Sep 17 00:00:00 2001 From: max <> Date: Tue, 17 Mar 2026 23:40:22 -0700 Subject: [PATCH 8/8] 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> --- acp_adapter/session.py | 2 + agent/auxiliary_client.py | 68 ++- agent/copilot_acp_client.py | 447 ++++++++++++++++++ agent/smart_model_routing.py | 12 + cli.py | 62 ++- cron/scheduler.py | 4 + gateway/run.py | 4 + hermes_cli/__init__.py | 4 +- hermes_cli/auth.py | 167 ++++++- hermes_cli/main.py | 365 +++++++++++++- hermes_cli/models.py | 349 +++++++++++++- hermes_cli/runtime_provider.py | 39 +- hermes_cli/setup.py | 241 +++++++++- pyproject.toml | 2 +- run_agent.py | 121 ++++- tests/agent/test_auxiliary_client.py | 25 + tests/hermes_cli/test_model_validation.py | 101 ++++ tests/hermes_cli/test_setup_model_provider.py | 148 ++++++ tests/test_api_key_providers.py | 172 ++++++- tests/test_model_provider_persistence.py | 113 +++++ tests/test_run_agent.py | 57 +++ tests/test_run_agent_codex_responses.py | 43 ++ tools/delegate_tool.py | 13 +- website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 2 +- website/docs/user-guide/configuration.md | 31 +- 26 files changed, 2472 insertions(+), 122 deletions(-) create mode 100644 agent/copilot_acp_client.py diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 8590a62e49..0f5b2428e1 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -194,6 +194,8 @@ class SessionManager: "api_mode": runtime.get("api_mode"), "base_url": runtime.get("base_url"), "api_key": runtime.get("api_key"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), } ) except Exception: diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 94be9d6fef..22b967fd2b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -480,11 +480,11 @@ def _read_codex_access_token() -> Optional[str]: def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: """Try each API-key provider in PROVIDER_REGISTRY order. - Returns (client, model) for the first provider whose env var is set, - or (None, None) if none are configured. + Returns (client, model) for the first provider with usable runtime + credentials, or (None, None) if none are configured. """ try: - from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials except ImportError: logger.debug("Could not import PROVIDER_REGISTRY for API-key fallback") return None, None @@ -492,34 +492,24 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: for provider_id, pconfig in PROVIDER_REGISTRY.items(): if pconfig.auth_type != "api_key": continue - # Check if any of the provider's env vars are set - api_key = "" - for env_var in pconfig.api_key_env_vars: - val = os.getenv(env_var, "").strip() - if val: - api_key = val - break - if not api_key: - continue if provider_id == "anthropic": return _try_anthropic() - # Resolve base URL (with optional env-var override) - # Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1 - env_url = "" - if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if env_url: - base_url = env_url.rstrip("/") - elif provider_id == "kimi-coding" and api_key.startswith("sk-kimi-"): - base_url = "https://api.kimi.com/coding/v1" - else: - base_url = pconfig.inference_base_url + creds = resolve_api_key_provider_credentials(provider_id) + api_key = str(creds.get("api_key", "")).strip() + if not api_key: + continue + + base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default") logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model) extra = {} if "api.kimi.com" in base_url.lower(): extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"} + elif "api.githubcopilot.com" in base_url.lower(): + from hermes_cli.models import copilot_default_headers + + extra["default_headers"] = copilot_default_headers() return OpenAI(api_key=api_key, base_url=base_url, **extra), model return None, None @@ -744,6 +734,10 @@ def _to_async_client(sync_client, model: str): base_lower = str(sync_client.base_url).lower() if "openrouter" in base_lower: async_kwargs["default_headers"] = dict(_OR_HEADERS) + elif "api.githubcopilot.com" in base_lower: + from hermes_cli.models import copilot_default_headers + + async_kwargs["default_headers"] = copilot_default_headers() elif "api.kimi.com" in base_lower: async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"} return AsyncOpenAI(**async_kwargs), model @@ -885,7 +879,7 @@ def resolve_provider_client( # ── API-key providers from PROVIDER_REGISTRY ───────────────────── try: - from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_kimi_base_url + from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials except ImportError: logger.debug("hermes_cli.auth not available for provider %s", provider) return None, None @@ -904,26 +898,18 @@ def resolve_provider_client( final_model = model or default_model return (_to_async_client(client, final_model) if async_mode else (client, final_model)) - # Find the first configured API key - api_key = "" - for env_var in pconfig.api_key_env_vars: - api_key = os.getenv(env_var, "").strip() - if api_key: - break + creds = resolve_api_key_provider_credentials(provider) + api_key = str(creds.get("api_key", "")).strip() if not api_key: + tried_sources = list(pconfig.api_key_env_vars) + if provider == "copilot": + tried_sources.append("gh auth token") logger.warning("resolve_provider_client: provider %s has no API " "key configured (tried: %s)", - provider, ", ".join(pconfig.api_key_env_vars)) + provider, ", ".join(tried_sources)) return None, None - # Resolve base URL (env override → provider-specific logic → default) - base_url_override = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" - if provider == "kimi-coding": - base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, base_url_override) - elif base_url_override: - base_url = base_url_override - else: - base_url = pconfig.inference_base_url + base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "") final_model = model or default_model @@ -932,6 +918,10 @@ def resolve_provider_client( headers = {} if "api.kimi.com" in base_url.lower(): headers["User-Agent"] = "KimiCLI/1.0" + elif "api.githubcopilot.com" in base_url.lower(): + from hermes_cli.models import copilot_default_headers + + headers.update(copilot_default_headers()) client = OpenAI(api_key=api_key, base_url=base_url, **({"default_headers": headers} if headers else {})) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py new file mode 100644 index 0000000000..7b8f45d9c8 --- /dev/null +++ b/agent/copilot_acp_client.py @@ -0,0 +1,447 @@ +"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`. + +This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style +backend. Each request starts a short-lived ACP session, sends the formatted +conversation as a single prompt, collects text chunks, and converts the result +back into the minimal shape Hermes expects from an OpenAI client. +""" + +from __future__ import annotations + +import json +import os +import queue +import shlex +import subprocess +import threading +import time +from collections import deque +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +ACP_MARKER_BASE_URL = "acp://copilot" +_DEFAULT_TIMEOUT_SECONDS = 900.0 + + +def _resolve_command() -> str: + return ( + os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() + or os.getenv("COPILOT_CLI_PATH", "").strip() + or "copilot" + ) + + +def _resolve_args() -> list[str]: + raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() + if not raw: + return ["--acp", "--stdio"] + return shlex.split(raw) + + +def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": code, + "message": message, + }, + } + + +def _format_messages_as_prompt(messages: list[dict[str, Any]], model: str | None = None) -> str: + sections: list[str] = [ + "You are being used as the active ACP agent backend for Hermes.", + "Use your own ACP capabilities and respond directly in natural language.", + "Do not emit OpenAI tool-call JSON.", + ] + if model: + sections.append(f"Hermes requested model hint: {model}") + + transcript: list[str] = [] + for message in messages: + if not isinstance(message, dict): + continue + role = str(message.get("role") or "unknown").strip().lower() + if role == "tool": + role = "tool" + elif role not in {"system", "user", "assistant"}: + role = "context" + + content = message.get("content") + rendered = _render_message_content(content) + if not rendered: + continue + + label = { + "system": "System", + "user": "User", + "assistant": "Assistant", + "tool": "Tool", + "context": "Context", + }.get(role, role.title()) + transcript.append(f"{label}:\n{rendered}") + + if transcript: + sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) + + sections.append("Continue the conversation from the latest user request.") + return "\n\n".join(section.strip() for section in sections if section and section.strip()) + + +def _render_message_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content.strip() + if isinstance(content, dict): + if "text" in content: + return str(content.get("text") or "").strip() + if "content" in content and isinstance(content.get("content"), str): + return str(content.get("content") or "").strip() + return json.dumps(content, ensure_ascii=True) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + return str(content).strip() + + +def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: + candidate = Path(path_text) + if not candidate.is_absolute(): + raise PermissionError("ACP file-system paths must be absolute.") + resolved = candidate.resolve() + root = Path(cwd).resolve() + try: + resolved.relative_to(root) + except ValueError as exc: + raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc + return resolved + + +class _ACPChatCompletions: + def __init__(self, client: "CopilotACPClient"): + self._client = client + + def create(self, **kwargs: Any) -> Any: + return self._client._create_chat_completion(**kwargs) + + +class _ACPChatNamespace: + def __init__(self, client: "CopilotACPClient"): + self.completions = _ACPChatCompletions(client) + + +class CopilotACPClient: + """Minimal OpenAI-client-compatible facade for Copilot ACP.""" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + default_headers: dict[str, str] | None = None, + acp_command: str | None = None, + acp_args: list[str] | None = None, + acp_cwd: str | None = None, + command: str | None = None, + args: list[str] | None = None, + **_: Any, + ): + self.api_key = api_key or "copilot-acp" + self.base_url = base_url or ACP_MARKER_BASE_URL + self._default_headers = dict(default_headers or {}) + self._acp_command = acp_command or command or _resolve_command() + self._acp_args = list(acp_args or args or _resolve_args()) + self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve()) + self.chat = _ACPChatNamespace(self) + self.is_closed = False + self._active_process: subprocess.Popen[str] | None = None + self._active_process_lock = threading.Lock() + + def close(self) -> None: + proc: subprocess.Popen[str] | None + with self._active_process_lock: + proc = self._active_process + self._active_process = None + self.is_closed = True + if proc is None: + return + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + def _create_chat_completion( + self, + *, + model: str | None = None, + messages: list[dict[str, Any]] | None = None, + timeout: float | None = None, + **_: Any, + ) -> Any: + prompt_text = _format_messages_as_prompt(messages or [], model=model) + response_text, reasoning_text = self._run_prompt( + prompt_text, + timeout_seconds=float(timeout or _DEFAULT_TIMEOUT_SECONDS), + ) + + usage = SimpleNamespace( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + prompt_tokens_details=SimpleNamespace(cached_tokens=0), + ) + assistant_message = SimpleNamespace( + content=response_text, + tool_calls=[], + reasoning=reasoning_text or None, + reasoning_content=reasoning_text or None, + reasoning_details=None, + ) + choice = SimpleNamespace(message=assistant_message, finish_reason="stop") + return SimpleNamespace( + choices=[choice], + usage=usage, + model=model or "copilot-acp", + ) + + def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]: + try: + proc = subprocess.Popen( + [self._acp_command] + self._acp_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=self._acp_cwd, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"Could not start Copilot ACP command '{self._acp_command}'. " + "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." + ) from exc + + if proc.stdin is None or proc.stdout is None: + proc.kill() + raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.") + + self.is_closed = False + with self._active_process_lock: + self._active_process = proc + + inbox: queue.Queue[dict[str, Any]] = queue.Queue() + stderr_tail: deque[str] = deque(maxlen=40) + + def _stdout_reader() -> None: + for line in proc.stdout: + try: + inbox.put(json.loads(line)) + except Exception: + inbox.put({"raw": line.rstrip("\n")}) + + def _stderr_reader() -> None: + if proc.stderr is None: + return + for line in proc.stderr: + stderr_tail.append(line.rstrip("\n")) + + out_thread = threading.Thread(target=_stdout_reader, daemon=True) + err_thread = threading.Thread(target=_stderr_reader, daemon=True) + out_thread.start() + err_thread.start() + + next_id = 0 + + def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any: + nonlocal next_id + next_id += 1 + request_id = next_id + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + proc.stdin.write(json.dumps(payload) + "\n") + proc.stdin.flush() + + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if proc.poll() is not None: + break + try: + msg = inbox.get(timeout=0.1) + except queue.Empty: + continue + + if self._handle_server_message( + msg, + process=proc, + cwd=self._acp_cwd, + text_parts=text_parts, + reasoning_parts=reasoning_parts, + ): + continue + + if msg.get("id") != request_id: + continue + if "error" in msg: + err = msg.get("error") or {} + raise RuntimeError( + f"Copilot ACP {method} failed: {err.get('message') or err}" + ) + return msg.get("result") + + stderr_text = "\n".join(stderr_tail).strip() + if proc.poll() is not None and stderr_text: + raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}") + raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.") + + try: + _request( + "initialize", + { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": True, + "writeTextFile": True, + } + }, + "clientInfo": { + "name": "hermes-agent", + "title": "Hermes Agent", + "version": "0.0.0", + }, + }, + ) + session = _request( + "session/new", + { + "cwd": self._acp_cwd, + "mcpServers": [], + }, + ) or {} + session_id = str(session.get("sessionId") or "").strip() + if not session_id: + raise RuntimeError("Copilot ACP did not return a sessionId.") + + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + _request( + "session/prompt", + { + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": prompt_text, + } + ], + }, + text_parts=text_parts, + reasoning_parts=reasoning_parts, + ) + return "".join(text_parts).strip(), "".join(reasoning_parts).strip() + finally: + self.close() + + def _handle_server_message( + self, + msg: dict[str, Any], + *, + process: subprocess.Popen[str], + cwd: str, + text_parts: list[str] | None, + reasoning_parts: list[str] | None, + ) -> bool: + method = msg.get("method") + if not isinstance(method, str): + return False + + if method == "session/update": + params = msg.get("params") or {} + update = params.get("update") or {} + kind = str(update.get("sessionUpdate") or "").strip() + content = update.get("content") or {} + chunk_text = "" + if isinstance(content, dict): + chunk_text = str(content.get("text") or "").strip() + if kind == "agent_message_chunk" and chunk_text and text_parts is not None: + text_parts.append(chunk_text) + elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None: + reasoning_parts.append(chunk_text) + return True + + if process.stdin is None: + return True + + message_id = msg.get("id") + params = msg.get("params") or {} + + if method == "session/request_permission": + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "outcome": { + "outcome": "allow_once", + } + }, + } + elif method == "fs/read_text_file": + try: + path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) + content = path.read_text() if path.exists() else "" + line = params.get("line") + limit = params.get("limit") + if isinstance(line, int) and line > 1: + lines = content.splitlines(keepends=True) + start = line - 1 + end = start + limit if isinstance(limit, int) and limit > 0 else None + content = "".join(lines[start:end]) + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "content": content, + }, + } + except Exception as exc: + response = _jsonrpc_error(message_id, -32602, str(exc)) + elif method == "fs/write_text_file": + try: + path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(params.get("content") or "")) + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": None, + } + except Exception as exc: + response = _jsonrpc_error(message_id, -32602, str(exc)) + else: + response = _jsonrpc_error( + message_id, + -32601, + f"ACP client method '{method}' is not supported by Hermes yet.", + ) + + process.stdin.write(json.dumps(response) + "\n") + process.stdin.flush() + return True diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index 2495487013..d57cd1b83a 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -125,6 +125,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "base_url": primary.get("base_url"), "provider": primary.get("provider"), "api_mode": primary.get("api_mode"), + "command": primary.get("command"), + "args": list(primary.get("args") or []), }, "label": None, "signature": ( @@ -132,6 +134,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any primary.get("provider"), primary.get("base_url"), primary.get("api_mode"), + primary.get("command"), + tuple(primary.get("args") or ()), ), } @@ -156,6 +160,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "base_url": primary.get("base_url"), "provider": primary.get("provider"), "api_mode": primary.get("api_mode"), + "command": primary.get("command"), + "args": list(primary.get("args") or []), }, "label": None, "signature": ( @@ -163,6 +169,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any primary.get("provider"), primary.get("base_url"), primary.get("api_mode"), + primary.get("command"), + tuple(primary.get("args") or ()), ), } @@ -173,6 +181,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "base_url": runtime.get("base_url"), "provider": runtime.get("provider"), "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), }, "label": f"smart route → {route.get('model')} ({runtime.get('provider')})", "signature": ( @@ -180,5 +190,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any runtime.get("provider"), runtime.get("base_url"), runtime.get("api_mode"), + runtime.get("command"), + tuple(runtime.get("args") or ()), ), } diff --git a/cli.py b/cli.py index 19a0c972f7..7df55a92cc 100755 --- a/cli.py +++ b/cli.py @@ -1063,6 +1063,8 @@ class HermesCLI: self._provider_source: Optional[str] = None self.provider = self.requested_provider self.api_mode = "chat_completions" + self.acp_command: Optional[str] = None + self.acp_args: list[str] = [] self.base_url = ( base_url or os.getenv("OPENAI_BASE_URL") @@ -1374,27 +1376,35 @@ class HermesCLI: return [("class:status-bar", f" {self._build_status_bar_text()} ")] def _normalize_model_for_provider(self, resolved_provider: str) -> bool: - """Strip provider prefixes and swap the default model for Codex. - - When the resolved provider is ``openai-codex``: - - 1. Strip any ``provider/`` prefix (the Codex Responses API only - accepts bare model slugs like ``gpt-5.4``, not ``openai/gpt-5.4``). - 2. If the active model is still the *untouched default* (user never - explicitly chose a model), replace it with a Codex-compatible - default so the first session doesn't immediately error. - - If the user explicitly chose a model — *any* model — we trust them - and let the API be the judge. No allowlists, no slug checks. - - Returns True when the active model was changed. - """ - if resolved_provider != "openai-codex": - return False - + """Normalize provider-specific model IDs and routing.""" current_model = (self.model or "").strip() changed = False + if resolved_provider == "copilot": + try: + from hermes_cli.models import copilot_model_api_mode, normalize_copilot_model_id + + canonical = normalize_copilot_model_id(current_model, api_key=self.api_key) + if canonical and canonical != current_model: + if not self._model_is_default: + self.console.print( + f"[yellow]⚠️ Normalized Copilot model '{current_model}' to '{canonical}'.[/]" + ) + self.model = canonical + current_model = canonical + changed = True + + resolved_mode = copilot_model_api_mode(current_model, api_key=self.api_key) + if resolved_mode != self.api_mode: + self.api_mode = resolved_mode + changed = True + except Exception: + pass + return changed + + if resolved_provider != "openai-codex": + return False + # 1. Strip provider prefix ("openai/gpt-5.4" → "gpt-5.4") if "/" in current_model: slug = current_model.split("/", 1)[1] @@ -1670,6 +1680,8 @@ class HermesCLI: base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") resolved_api_mode = runtime.get("api_mode", self.api_mode) + resolved_acp_command = runtime.get("command") + resolved_acp_args = list(runtime.get("args") or []) if not isinstance(api_key, str) or not api_key: self.console.print("[bold red]Provider resolver returned an empty API key.[/]") return False @@ -1681,9 +1693,13 @@ class HermesCLI: routing_changed = ( resolved_provider != self.provider or resolved_api_mode != self.api_mode + or resolved_acp_command != self.acp_command + or resolved_acp_args != self.acp_args ) self.provider = resolved_provider self.api_mode = resolved_api_mode + self.acp_command = resolved_acp_command + self.acp_args = resolved_acp_args self._provider_source = runtime.get("source") self.api_key = api_key self.base_url = base_url @@ -1713,6 +1729,8 @@ class HermesCLI: "base_url": self.base_url, "provider": self.provider, "api_mode": self.api_mode, + "command": self.acp_command, + "args": list(self.acp_args or []), }, ) @@ -1781,6 +1799,8 @@ class HermesCLI: "base_url": self.base_url, "provider": self.provider, "api_mode": self.api_mode, + "command": self.acp_command, + "args": list(self.acp_args or []), } effective_model = model_override or self.model self.agent = AIAgent( @@ -1789,6 +1809,8 @@ class HermesCLI: base_url=runtime.get("base_url"), provider=runtime.get("provider"), api_mode=runtime.get("api_mode"), + acp_command=runtime.get("command"), + acp_args=runtime.get("args"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, verbose_logging=self.verbose, @@ -1825,6 +1847,8 @@ class HermesCLI: runtime.get("provider"), runtime.get("base_url"), runtime.get("api_mode"), + runtime.get("command"), + tuple(runtime.get("args") or ()), ) if self._pending_title and self._session_db: @@ -3750,6 +3774,8 @@ class HermesCLI: base_url=turn_route["runtime"].get("base_url"), provider=turn_route["runtime"].get("provider"), api_mode=turn_route["runtime"].get("api_mode"), + acp_command=turn_route["runtime"].get("command"), + acp_args=turn_route["runtime"].get("args"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, quiet_mode=True, diff --git a/cron/scheduler.py b/cron/scheduler.py index 2060bf2fbe..ea7ff0e9bf 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -359,6 +359,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: "base_url": runtime.get("base_url"), "provider": runtime.get("provider"), "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), }, ) @@ -368,6 +370,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: base_url=turn_route["runtime"].get("base_url"), provider=turn_route["runtime"].get("provider"), api_mode=turn_route["runtime"].get("api_mode"), + acp_command=turn_route["runtime"].get("command"), + acp_args=turn_route["runtime"].get("args"), max_iterations=max_iterations, reasoning_config=reasoning_config, prefill_messages=prefill_messages, diff --git a/gateway/run.py b/gateway/run.py index ea9f2a2838..4e9666a905 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -242,6 +242,8 @@ def _resolve_runtime_agent_kwargs() -> dict: "base_url": runtime.get("base_url"), "provider": runtime.get("provider"), "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), } @@ -601,6 +603,8 @@ class GatewayRunner: "base_url": runtime_kwargs.get("base_url"), "provider": runtime_kwargs.get("provider"), "api_mode": runtime_kwargs.get("api_mode"), + "command": runtime_kwargs.get("command"), + "args": list(runtime_kwargs.get("args") or []), } return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 90f082720f..eea32d6db2 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -11,5 +11,5 @@ Provides subcommands for: - hermes cron - Manage cron jobs """ -__version__ = "0.3.0" -__release_date__ = "2026.3.17" +__version__ = "0.4.0" +__release_date__ = "2026.3.18" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 54573acf18..f73506371f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -19,6 +19,7 @@ import json import logging import os import shutil +import shlex import stat import base64 import hashlib @@ -66,6 +67,8 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" +DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @@ -108,6 +111,20 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), + "copilot": ProviderConfig( + id="copilot", + name="GitHub Copilot", + auth_type="api_key", + inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL, + api_key_env_vars=("GITHUB_TOKEN", "GH_TOKEN"), + ), + "copilot-acp": ProviderConfig( + id="copilot-acp", + name="GitHub Copilot ACP", + auth_type="external_process", + inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, + base_url_env_var="COPILOT_ACP_BASE_URL", + ), "zai": ProviderConfig( id="zai", name="Z.AI / GLM", @@ -222,6 +239,62 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> return default_url +def _gh_cli_candidates() -> list[str]: + """Return candidate ``gh`` binary paths, including common Homebrew installs.""" + candidates: list[str] = [] + + resolved = shutil.which("gh") + if resolved: + candidates.append(resolved) + + for candidate in ( + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + str(Path.home() / ".local" / "bin" / "gh"), + ): + if candidate in candidates: + continue + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + candidates.append(candidate) + + return candidates + + +def _try_gh_cli_token() -> Optional[str]: + """Return a token from ``gh auth token`` when the GitHub CLI is available.""" + for gh_path in _gh_cli_candidates(): + try: + result = subprocess.run( + [gh_path, "auth", "token"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) + continue + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + return None + + +def _resolve_api_key_provider_secret( + provider_id: str, pconfig: ProviderConfig +) -> tuple[str, str]: + """Resolve an API-key provider's token and indicate where it came from.""" + for env_var in pconfig.api_key_env_vars: + val = os.getenv(env_var, "").strip() + if val: + return val, env_var + + if provider_id == "copilot": + token = _try_gh_cli_token() + if token: + return token, "gh auth token" + + return "", "" + + # ============================================================================= # Z.AI Endpoint Detection # ============================================================================= @@ -572,6 +645,9 @@ def resolve_provider( "kimi": "kimi-coding", "moonshot": "kimi-coding", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", + "github": "copilot", "github-copilot": "copilot", + "github-models": "copilot", "github-model": "copilot", + "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", "go": "opencode-go", "opencode-go-sub": "opencode-go", @@ -611,6 +687,11 @@ def resolve_provider( for pid, pconfig in PROVIDER_REGISTRY.items(): if pconfig.auth_type != "api_key": continue + # GitHub tokens are commonly present for repo/tool access but should not + # hijack inference auto-selection unless the user explicitly chooses + # Copilot/GitHub Models as the provider. + if pid == "copilot": + continue for env_var in pconfig.api_key_env_vars: if os.getenv(env_var, "").strip(): return pid @@ -1479,12 +1560,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: api_key = "" key_source = "" - for env_var in pconfig.api_key_env_vars: - val = os.getenv(env_var, "").strip() - if val: - api_key = val - key_source = env_var - break + api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) env_url = "" if pconfig.base_url_env_var: @@ -1507,6 +1583,36 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: } +def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: + """Status snapshot for providers that run a local subprocess.""" + pconfig = PROVIDER_REGISTRY.get(provider_id) + if not pconfig or pconfig.auth_type != "external_process": + return {"configured": False} + + command = ( + os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() + or os.getenv("COPILOT_CLI_PATH", "").strip() + or "copilot" + ) + raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() + args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] + base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" + if not base_url: + base_url = pconfig.inference_base_url + + resolved_command = shutil.which(command) if command else None + return { + "configured": bool(resolved_command or base_url.startswith("acp+tcp://")), + "provider": provider_id, + "name": pconfig.name, + "command": command, + "args": args, + "resolved_command": resolved_command, + "base_url": base_url, + "logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")), + } + + def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: """Generic auth status dispatcher.""" target = provider_id or get_active_provider() @@ -1514,6 +1620,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_nous_auth_status() if target == "openai-codex": return get_codex_auth_status() + if target == "copilot-acp": + return get_external_process_provider_status(target) # API-key providers pconfig = PROVIDER_REGISTRY.get(target) if pconfig and pconfig.auth_type == "api_key": @@ -1536,12 +1644,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: api_key = "" key_source = "" - for env_var in pconfig.api_key_env_vars: - val = os.getenv(env_var, "").strip() - if val: - api_key = val - key_source = env_var - break + api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) env_url = "" if pconfig.base_url_env_var: @@ -1562,6 +1665,46 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: } +def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]: + """Resolve runtime details for local subprocess-backed providers.""" + pconfig = PROVIDER_REGISTRY.get(provider_id) + if not pconfig or pconfig.auth_type != "external_process": + raise AuthError( + f"Provider '{provider_id}' is not an external-process provider.", + provider=provider_id, + code="invalid_provider", + ) + + base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" + if not base_url: + base_url = pconfig.inference_base_url + + command = ( + os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() + or os.getenv("COPILOT_CLI_PATH", "").strip() + or "copilot" + ) + raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() + args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] + resolved_command = shutil.which(command) if command else None + if not resolved_command and not base_url.startswith("acp+tcp://"): + raise AuthError( + f"Could not find the Copilot CLI command '{command}'. " + "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.", + provider=provider_id, + code="missing_copilot_cli", + ) + + return { + "provider": provider_id, + "api_key": "copilot-acp", + "base_url": base_url.rstrip("/"), + "command": resolved_command or command, + "args": args, + "source": "process", + } + + # ============================================================================= # External credential detection # ============================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d5d4885a74..a578c4d7dc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -125,6 +125,17 @@ def _has_any_provider_configured() -> bool: except Exception: pass + # Check provider-specific auth fallbacks (for example, Copilot via gh auth). + try: + for provider_id, pconfig in PROVIDER_REGISTRY.items(): + if pconfig.auth_type != "api_key": + continue + status = get_auth_status(provider_id) + if status.get("logged_in"): + return True + except Exception: + pass + # Check for Nous Portal OAuth credentials auth_file = get_hermes_home() / "auth.json" if auth_file.exists(): @@ -775,6 +786,8 @@ def cmd_model(args): "openrouter": "OpenRouter", "nous": "Nous Portal", "openai-codex": "OpenAI Codex", + "copilot-acp": "GitHub Copilot ACP", + "copilot": "GitHub Copilot", "anthropic": "Anthropic", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", @@ -799,6 +812,8 @@ def cmd_model(args): ("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("nous", "Nous Portal (Nous Research subscription)"), ("openai-codex", "OpenAI Codex"), + ("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), + ("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"), ("anthropic", "Anthropic (Claude models — API key or Claude Code)"), ("zai", "Z.AI / GLM (Zhipu AI direct API)"), ("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"), @@ -867,6 +882,10 @@ def cmd_model(args): _model_flow_nous(config, current_model) elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) + elif selected_provider == "copilot-acp": + _model_flow_copilot_acp(config, current_model) + elif selected_provider == "copilot": + _model_flow_copilot(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map: @@ -1407,6 +1426,25 @@ def _model_flow_named_custom(config, provider_info): # Curated model lists for direct API-key providers _PROVIDER_MODELS = { + "copilot-acp": [ + "copilot-acp", + ], + "copilot": [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-mini", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-4.1", + "gpt-4o", + "gpt-4o-mini", + "claude-opus-4.6", + "claude-sonnet-4.6", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "gemini-2.5-pro", + "grok-code-fast-1", + ], "zai": [ "glm-5", "glm-4.7", @@ -1447,6 +1485,331 @@ _PROVIDER_MODELS = { } +def _current_reasoning_effort(config) -> str: + agent_cfg = config.get("agent") + if isinstance(agent_cfg, dict): + return str(agent_cfg.get("reasoning_effort") or "").strip().lower() + return "" + + +def _set_reasoning_effort(config, effort: str) -> None: + agent_cfg = config.get("agent") + if not isinstance(agent_cfg, dict): + agent_cfg = {} + config["agent"] = agent_cfg + agent_cfg["reasoning_effort"] = effort + + +def _prompt_reasoning_effort_selection(efforts, current_effort=""): + """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" + ordered = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip())) + if not ordered: + return None + + def _label(effort): + if effort == current_effort: + return f"{effort} ← currently in use" + return effort + + disable_label = "Disable reasoning" + skip_label = "Skip (keep current)" + + if current_effort == "none": + default_idx = len(ordered) + elif current_effort in ordered: + default_idx = ordered.index(current_effort) + elif "medium" in ordered: + default_idx = ordered.index("medium") + else: + default_idx = 0 + + try: + from simple_term_menu import TerminalMenu + + choices = [f" {_label(effort)}" for effort in ordered] + choices.append(f" {disable_label}") + choices.append(f" {skip_label}") + menu = TerminalMenu( + choices, + cursor_index=default_idx, + menu_cursor="-> ", + menu_cursor_style=("fg_green", "bold"), + menu_highlight_style=("fg_green",), + cycle_cursor=True, + clear_screen=False, + title="Select reasoning effort:", + ) + idx = menu.show() + if idx is None: + return None + print() + if idx < len(ordered): + return ordered[idx] + if idx == len(ordered): + return "none" + return None + except (ImportError, NotImplementedError): + pass + + print("Select reasoning effort:") + for i, effort in enumerate(ordered, 1): + print(f" {i}. {_label(effort)}") + n = len(ordered) + print(f" {n + 1}. {disable_label}") + print(f" {n + 2}. {skip_label}") + print() + + while True: + try: + choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip() + if not choice: + return None + idx = int(choice) + if 1 <= idx <= n: + return ordered[idx - 1] + if idx == n + 1: + return "none" + if idx == n + 2: + return None + print(f"Please enter 1-{n + 2}") + except ValueError: + print("Please enter a number") + except (KeyboardInterrupt, EOFError): + return None + + +def _model_flow_copilot(config, current_model=""): + """GitHub Copilot flow using env vars or ``gh auth token``.""" + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + resolve_api_key_provider_credentials, + ) + from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.models import ( + fetch_api_models, + fetch_github_model_catalog, + github_model_reasoning_efforts, + copilot_model_api_mode, + normalize_copilot_model_id, + ) + + provider_id = "copilot" + pconfig = PROVIDER_REGISTRY[provider_id] + + creds = resolve_api_key_provider_credentials(provider_id) + api_key = creds.get("api_key", "") + source = creds.get("source", "") + + if not api_key: + print("No GitHub token configured for GitHub Copilot.") + print(" Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.") + try: + new_key = input("GITHUB_TOKEN (or Enter to cancel): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not new_key: + print("Cancelled.") + return + save_env_value("GITHUB_TOKEN", new_key) + print("GitHub token saved.") + print() + creds = resolve_api_key_provider_credentials(provider_id) + api_key = creds.get("api_key", "") + source = creds.get("source", "") + else: + if source in ("GITHUB_TOKEN", "GH_TOKEN"): + print(f" GitHub token: {api_key[:8]}... ✓ ({source})") + elif source == "gh auth token": + print(" GitHub token: ✓ (from `gh auth token`)") + else: + print(" GitHub token: ✓") + print() + + effective_base = pconfig.inference_base_url + + catalog = fetch_github_model_catalog(api_key) + live_models = [item.get("id", "") for item in catalog if item.get("id")] if catalog else fetch_api_models(api_key, effective_base) + normalized_current_model = normalize_copilot_model_id( + current_model, + catalog=catalog, + api_key=api_key, + ) or current_model + if live_models: + model_list = [model_id for model_id in live_models if model_id] + print(f" Found {len(model_list)} model(s) from GitHub Copilot") + else: + model_list = _PROVIDER_MODELS.get(provider_id, []) + if model_list: + print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.") + print(' Use "Enter custom model name" if you do not see your model.') + + if model_list: + selected = _prompt_model_selection(model_list, current_model=normalized_current_model) + else: + try: + selected = input("Model name: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + selected = normalize_copilot_model_id( + selected, + catalog=catalog, + api_key=api_key, + ) or selected + # Clear stale custom-endpoint overrides so the Copilot provider wins cleanly. + if get_env_value("OPENAI_BASE_URL"): + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + + initial_cfg = load_config() + current_effort = _current_reasoning_effort(initial_cfg) + reasoning_efforts = github_model_reasoning_efforts( + selected, + catalog=catalog, + api_key=api_key, + ) + selected_effort = None + if reasoning_efforts: + print(f" {selected} supports reasoning controls.") + selected_effort = _prompt_reasoning_effort_selection( + reasoning_efforts, current_effort=current_effort + ) + + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = provider_id + model["base_url"] = effective_base + model["api_mode"] = copilot_model_api_mode( + selected, + catalog=catalog, + api_key=api_key, + ) + if selected_effort is not None: + _set_reasoning_effort(cfg, selected_effort) + save_config(cfg) + deactivate_provider() + + print(f"Default model set to: {selected} (via {pconfig.name})") + if reasoning_efforts: + if selected_effort == "none": + print("Reasoning disabled for this model.") + elif selected_effort: + print(f"Reasoning effort set to: {selected_effort}") + else: + print("No change.") + + +def _model_flow_copilot_acp(config, current_model=""): + """GitHub Copilot ACP flow using the local Copilot CLI.""" + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + get_external_process_provider_status, + resolve_api_key_provider_credentials, + resolve_external_process_provider_credentials, + ) + from hermes_cli.models import ( + fetch_github_model_catalog, + normalize_copilot_model_id, + ) + from hermes_cli.config import load_config, save_config + + del config + + provider_id = "copilot-acp" + pconfig = PROVIDER_REGISTRY[provider_id] + + status = get_external_process_provider_status(provider_id) + resolved_command = status.get("resolved_command") or status.get("command") or "copilot" + effective_base = status.get("base_url") or pconfig.inference_base_url + + print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.") + print(" Hermes currently starts its own ACP subprocess for each request.") + print(" Hermes uses your selected model as a hint for the Copilot ACP session.") + print(f" Command: {resolved_command}") + print(f" Backend marker: {effective_base}") + print() + + try: + creds = resolve_external_process_provider_credentials(provider_id) + except Exception as exc: + print(f" ⚠ {exc}") + print(" Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere.") + return + + effective_base = creds.get("base_url") or effective_base + + catalog_api_key = "" + try: + catalog_creds = resolve_api_key_provider_credentials("copilot") + catalog_api_key = catalog_creds.get("api_key", "") + except Exception: + pass + + catalog = fetch_github_model_catalog(catalog_api_key) + normalized_current_model = normalize_copilot_model_id( + current_model, + catalog=catalog, + api_key=catalog_api_key, + ) or current_model + + if catalog: + model_list = [item.get("id", "") for item in catalog if item.get("id")] + print(f" Found {len(model_list)} model(s) from GitHub Copilot") + else: + model_list = _PROVIDER_MODELS.get("copilot", []) + if model_list: + print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.") + print(' Use "Enter custom model name" if you do not see your model.') + + if model_list: + selected = _prompt_model_selection( + model_list, + current_model=normalized_current_model, + ) + else: + try: + selected = input("Model name: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if not selected: + print("No change.") + return + + selected = normalize_copilot_model_id( + selected, + catalog=catalog, + api_key=catalog_api_key, + ) or selected + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = provider_id + model["base_url"] = effective_base + model["api_mode"] = "chat_completions" + save_config(cfg) + deactivate_provider() + + print(f"Default model set to: {selected} (via {pconfig.name})") + + def _model_flow_kimi(config, current_model=""): """Kimi / Moonshot model selection with automatic endpoint routing. @@ -2642,7 +3005,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"], + choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"], default=None, help="Inference provider (default: auto)" ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 174aa94750..e6f4bc5d56 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -14,6 +14,16 @@ import urllib.error from difflib import get_close_matches from typing import Any, Optional +COPILOT_BASE_URL = "https://api.githubcopilot.com" +COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" +COPILOT_EDITOR_VERSION = "vscode/1.104.1" +COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] +COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] + +# Backward-compatible aliases for the earlier GitHub Models-backed Copilot work. +GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL +GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL + # (model_id, display description shown in menus) OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-opus-4.6", "recommended"), @@ -46,6 +56,25 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-5.1-codex-mini", "gpt-5.1-codex-max", ], + "copilot-acp": [ + "copilot-acp", + ], + "copilot": [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-mini", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-4.1", + "gpt-4o", + "gpt-4o-mini", + "claude-opus-4.6", + "claude-sonnet-4.6", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "gemini-2.5-pro", + "grok-code-fast-1", + ], "zai": [ "glm-5", "glm-4.7", @@ -160,7 +189,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = { _PROVIDER_LABELS = { "openrouter": "OpenRouter", "openai-codex": "OpenAI Codex", + "copilot-acp": "GitHub Copilot ACP", "nous": "Nous Portal", + "copilot": "GitHub Copilot", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", "minimax": "MiniMax", @@ -180,6 +211,12 @@ _PROVIDER_ALIASES = { "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", + "github": "copilot", + "github-copilot": "copilot", + "github-models": "copilot", + "github-model": "copilot", + "github-copilot-acp": "copilot-acp", + "copilot-acp-agent": "copilot-acp", "kimi": "kimi-coding", "moonshot": "kimi-coding", "minimax-china": "minimax-cn", @@ -233,7 +270,7 @@ def list_available_providers() -> list[dict[str, str]]: """ # Canonical providers in display order _PROVIDER_ORDER = [ - "openrouter", "nous", "openai-codex", + "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", "opencode-zen", "opencode-go", "ai-gateway", "deepseek", "custom", @@ -454,6 +491,17 @@ def provider_label(provider: Optional[str]) -> str: return _PROVIDER_LABELS.get(normalized, original or "OpenRouter") +def _resolve_copilot_catalog_api_key() -> str: + """Best-effort GitHub token for fetching the Copilot model catalog.""" + try: + from hermes_cli.auth import resolve_api_key_provider_credentials + + creds = resolve_api_key_provider_credentials("copilot") + return str(creds.get("api_key") or "").strip() + except Exception: + return "" + + def provider_model_ids(provider: Optional[str]) -> list[str]: """Return the best known model catalog for a provider. @@ -467,6 +515,15 @@ def provider_model_ids(provider: Optional[str]) -> list[str]: from hermes_cli.codex_models import get_codex_model_ids return get_codex_model_ids() + if normalized in {"copilot", "copilot-acp"}: + try: + live = _fetch_github_models(_resolve_copilot_catalog_api_key()) + if live: + return live + except Exception: + pass + if normalized == "copilot-acp": + return list(_PROVIDER_MODELS.get("copilot", [])) if normalized == "nous": # Try live Nous Portal /models endpoint try: @@ -545,6 +602,274 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: return None +def _payload_items(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + data = payload.get("data", []) + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + return [] + + +def _extract_model_ids(payload: Any) -> list[str]: + return [item.get("id", "") for item in _payload_items(payload) if item.get("id")] + + +def copilot_default_headers() -> dict[str, str]: + return { + "Editor-Version": COPILOT_EDITOR_VERSION, + "User-Agent": "HermesAgent/1.0", + } + + +def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool: + model_id = str(item.get("id") or "").strip() + if not model_id: + return False + + if item.get("model_picker_enabled") is False: + return False + + capabilities = item.get("capabilities") + if isinstance(capabilities, dict): + model_type = str(capabilities.get("type") or "").strip().lower() + if model_type and model_type != "chat": + return False + + supported_endpoints = item.get("supported_endpoints") + if isinstance(supported_endpoints, list): + normalized_endpoints = { + str(endpoint).strip() + for endpoint in supported_endpoints + if str(endpoint).strip() + } + if normalized_endpoints and not normalized_endpoints.intersection( + {"/chat/completions", "/responses", "/v1/messages"} + ): + return False + + return True + + +def fetch_github_model_catalog( + api_key: Optional[str] = None, timeout: float = 5.0 +) -> Optional[list[dict[str, Any]]]: + """Fetch the live GitHub Copilot model catalog for this account.""" + attempts: list[dict[str, str]] = [] + if api_key: + attempts.append({ + **copilot_default_headers(), + "Authorization": f"Bearer {api_key}", + }) + attempts.append(copilot_default_headers()) + + for headers in attempts: + req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + items = _payload_items(data) + models: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for item in items: + if not _copilot_catalog_item_is_text_model(item): + continue + model_id = str(item.get("id") or "").strip() + if not model_id or model_id in seen_ids: + continue + seen_ids.add(model_id) + models.append(item) + if models: + return models + except Exception: + continue + return None + + +def _is_github_models_base_url(base_url: Optional[str]) -> bool: + normalized = (base_url or "").strip().rstrip("/").lower() + return ( + normalized.startswith(COPILOT_BASE_URL) + or normalized.startswith("https://models.github.ai/inference") + ) + + +def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]: + catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout) + if not catalog: + return None + return [item.get("id", "") for item in catalog if item.get("id")] + + +_COPILOT_MODEL_ALIASES = { + "openai/gpt-5": "gpt-5-mini", + "openai/gpt-5-chat": "gpt-5-mini", + "openai/gpt-5-mini": "gpt-5-mini", + "openai/gpt-5-nano": "gpt-5-mini", + "openai/gpt-4.1": "gpt-4.1", + "openai/gpt-4.1-mini": "gpt-4.1", + "openai/gpt-4.1-nano": "gpt-4.1", + "openai/gpt-4o": "gpt-4o", + "openai/gpt-4o-mini": "gpt-4o-mini", + "openai/o1": "gpt-5.2", + "openai/o1-mini": "gpt-5-mini", + "openai/o1-preview": "gpt-5.2", + "openai/o3": "gpt-5.3-codex", + "openai/o3-mini": "gpt-5-mini", + "openai/o4-mini": "gpt-5-mini", + "anthropic/claude-opus-4.6": "claude-opus-4.6", + "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6", + "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5", + "anthropic/claude-haiku-4.5": "claude-haiku-4.5", +} + + +def _copilot_catalog_ids( + catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None, +) -> set[str]: + if catalog is None and api_key: + catalog = fetch_github_model_catalog(api_key=api_key) + if not catalog: + return set() + return { + str(item.get("id") or "").strip() + for item in catalog + if str(item.get("id") or "").strip() + } + + +def normalize_copilot_model_id( + model_id: Optional[str], + *, + catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None, +) -> str: + raw = str(model_id or "").strip() + if not raw: + return "" + + catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key) + alias = _COPILOT_MODEL_ALIASES.get(raw) + if alias: + return alias + + candidates = [raw] + if "/" in raw: + candidates.append(raw.split("/", 1)[1].strip()) + + if raw.endswith("-mini"): + candidates.append(raw[:-5]) + if raw.endswith("-nano"): + candidates.append(raw[:-5]) + if raw.endswith("-chat"): + candidates.append(raw[:-5]) + + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + if candidate in _COPILOT_MODEL_ALIASES: + return _COPILOT_MODEL_ALIASES[candidate] + if candidate in catalog_ids: + return candidate + + if "/" in raw: + return raw.split("/", 1)[1].strip() + return raw + + +def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]: + raw = (model_id or "").strip().lower() + if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")): + return list(COPILOT_REASONING_EFFORTS_O_SERIES) + normalized = normalize_copilot_model_id(model_id).lower() + if normalized.startswith("gpt-5"): + return list(COPILOT_REASONING_EFFORTS_GPT5) + return [] + + +def copilot_model_api_mode( + model_id: Optional[str], + *, + catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None, +) -> str: + normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) + if not normalized: + return "chat_completions" + + if catalog is None and api_key: + catalog = fetch_github_model_catalog(api_key=api_key) + + catalog_entry = None + if catalog: + catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) + + if isinstance(catalog_entry, dict): + supported_endpoints = { + str(endpoint).strip() + for endpoint in (catalog_entry.get("supported_endpoints") or []) + if str(endpoint).strip() + } + if "/chat/completions" in supported_endpoints: + return "chat_completions" + if "/responses" in supported_endpoints: + return "codex_responses" + if "/v1/messages" in supported_endpoints: + return "anthropic_messages" + + if normalized.startswith(("gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex")): + return "codex_responses" + return "chat_completions" + + +def github_model_reasoning_efforts( + model_id: Optional[str], + *, + catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None, +) -> list[str]: + """Return supported reasoning-effort levels for a Copilot-visible model.""" + normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) + if not normalized: + return [] + + catalog_entry = None + if catalog is not None: + catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) + elif api_key: + fetched_catalog = fetch_github_model_catalog(api_key=api_key) + if fetched_catalog: + catalog_entry = next((item for item in fetched_catalog if item.get("id") == normalized), None) + + if catalog_entry is not None: + capabilities = catalog_entry.get("capabilities") + if isinstance(capabilities, dict): + supports = capabilities.get("supports") + if isinstance(supports, dict): + efforts = supports.get("reasoning_effort") + if isinstance(efforts, list): + normalized_efforts = [ + str(effort).strip().lower() + for effort in efforts + if str(effort).strip() + ] + return list(dict.fromkeys(normalized_efforts)) + return [] + legacy_capabilities = { + str(capability).strip().lower() + for capability in catalog_entry.get("capabilities", []) + if str(capability).strip() + } + if "reasoning" not in legacy_capabilities: + return [] + + return _github_reasoning_efforts_for_model_id(str(model_id or normalized)) + + def probe_api_models( api_key: Optional[str], base_url: Optional[str], @@ -561,6 +886,16 @@ def probe_api_models( "used_fallback": False, } + if _is_github_models_base_url(normalized): + models = _fetch_github_models(api_key=api_key, timeout=timeout) + return { + "models": models, + "probed_url": COPILOT_MODELS_URL, + "resolved_base_url": COPILOT_BASE_URL, + "suggested_base_url": None, + "used_fallback": False, + } + if normalized.endswith("/v1"): alternate_base = normalized[:-3].rstrip("/") else: @@ -574,6 +909,8 @@ def probe_api_models( headers: dict[str, str] = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" + if normalized.startswith(COPILOT_BASE_URL): + headers.update(copilot_default_headers()) for candidate_base, is_fallback in candidates: url = candidate_base.rstrip("/") + "/models" @@ -664,6 +1001,12 @@ def validate_requested_model( normalized = normalize_provider(provider) if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url: normalized = "custom" + requested_for_lookup = requested + if normalized == "copilot": + requested_for_lookup = normalize_copilot_model_id( + requested, + api_key=api_key, + ) or requested if not requested: return { @@ -685,7 +1028,7 @@ def validate_requested_model( probe = probe_api_models(api_key, base_url) api_models = probe.get("models") if api_models is not None: - if requested in set(api_models): + if requested_for_lookup in set(api_models): return { "accepted": True, "persist": True, @@ -734,7 +1077,7 @@ def validate_requested_model( api_models = fetch_api_models(api_key, base_url) if api_models is not None: - if requested in set(api_models): + if requested_for_lookup in set(api_models): # API confirmed the model exists return { "accepted": True, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 34ae43be8f..ae3948da5b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -14,6 +14,7 @@ from hermes_cli.auth import ( resolve_nous_runtime_credentials, resolve_codex_runtime_credentials, resolve_api_key_provider_credentials, + resolve_external_process_provider_credentials, ) from hermes_cli.config import load_config from hermes_constants import OPENROUTER_BASE_URL @@ -33,7 +34,24 @@ def _get_model_config() -> Dict[str, Any]: return {} -_VALID_API_MODES = {"chat_completions", "codex_responses"} +def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: + configured_mode = _parse_api_mode(model_cfg.get("api_mode")) + if configured_mode: + return configured_mode + + model_name = str(model_cfg.get("default") or "").strip() + if not model_name: + return "chat_completions" + + try: + from hermes_cli.models import copilot_model_api_mode + + return copilot_model_api_mode(model_name, api_key=api_key) + except Exception: + return "chat_completions" + + +_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"} def _parse_api_mode(raw: Any) -> Optional[str]: @@ -267,6 +285,19 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } + if provider == "copilot-acp": + creds = resolve_external_process_provider_credentials(provider) + return { + "provider": "copilot-acp", + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "command": creds.get("command", ""), + "args": list(creds.get("args") or []), + "source": creds.get("source", "process"), + "requested_provider": requested_provider, + } + # Anthropic (native Messages API) if provider == "anthropic": from agent.anthropic_adapter import resolve_anthropic_token @@ -302,9 +333,13 @@ def resolve_runtime_provider( pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": creds = resolve_api_key_provider_credentials(provider) + model_cfg = _get_model_config() + api_mode = "chat_completions" + if provider == "copilot": + api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) return { "provider": provider, - "api_mode": "chat_completions", + "api_mode": api_mode, "base_url": creds.get("base_url", "").rstrip("/"), "api_key": creds.get("api_key", ""), "source": creds.get("source", "env"), diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e3b5ed7d47..3264d7e471 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -55,6 +55,25 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None: # Default model lists per provider — used as fallback when the live # /models endpoint can't be reached. _DEFAULT_PROVIDER_MODELS = { + "copilot-acp": [ + "copilot-acp", + ], + "copilot": [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-mini", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-4.1", + "gpt-4o", + "gpt-4o-mini", + "claude-opus-4.6", + "claude-sonnet-4.6", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "gemini-2.5-pro", + "grok-code-fast-1", + ], "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.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"], @@ -64,6 +83,59 @@ _DEFAULT_PROVIDER_MODELS = { } +def _current_reasoning_effort(config: Dict[str, Any]) -> str: + agent_cfg = config.get("agent") + if isinstance(agent_cfg, dict): + return str(agent_cfg.get("reasoning_effort") or "").strip().lower() + return "" + + +def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None: + agent_cfg = config.get("agent") + if not isinstance(agent_cfg, dict): + agent_cfg = {} + config["agent"] = agent_cfg + agent_cfg["reasoning_effort"] = effort + + +def _setup_copilot_reasoning_selection( + config: Dict[str, Any], + model_id: str, + prompt_choice, + *, + catalog: Optional[list[dict[str, Any]]] = None, + api_key: str = "", +) -> None: + from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id + + normalized_model = normalize_copilot_model_id( + model_id, + catalog=catalog, + api_key=api_key, + ) or model_id + efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key) + if not efforts: + return + + current_effort = _current_reasoning_effort(config) + choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"] + + if current_effort == "none": + default_idx = len(efforts) + elif current_effort in efforts: + default_idx = efforts.index(current_effort) + elif "medium" in efforts: + default_idx = efforts.index("medium") + else: + default_idx = len(choices) - 1 + + effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx) + if effort_idx < len(efforts): + _set_reasoning_effort(config, efforts[effort_idx]) + elif effort_idx == len(efforts): + _set_reasoning_effort(config, "none") + + def _setup_provider_model_selection(config, provider_id, current_model, prompt_choice, prompt_fn): """Model selection for API-key providers with live /models detection. @@ -71,29 +143,60 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c hardcoded default list with a warning if the endpoint is unreachable. Always offers a 'Custom model' escape hatch. """ - from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials from hermes_cli.config import get_env_value - from hermes_cli.models import fetch_api_models + from hermes_cli.models import ( + copilot_model_api_mode, + fetch_api_models, + fetch_github_model_catalog, + normalize_copilot_model_id, + ) pconfig = PROVIDER_REGISTRY[provider_id] + is_copilot_catalog_provider = provider_id in {"copilot", "copilot-acp"} # Resolve API key and base URL for the probe - api_key = "" - for ev in pconfig.api_key_env_vars: - api_key = get_env_value(ev) or os.getenv(ev, "") - if api_key: - break - base_url_env = pconfig.base_url_env_var or "" - base_url = (get_env_value(base_url_env) if base_url_env else "") or pconfig.inference_base_url + if is_copilot_catalog_provider: + api_key = "" + if provider_id == "copilot": + creds = resolve_api_key_provider_credentials(provider_id) + api_key = creds.get("api_key", "") + base_url = creds.get("base_url", "") or pconfig.inference_base_url + else: + try: + creds = resolve_api_key_provider_credentials("copilot") + api_key = creds.get("api_key", "") + except Exception: + pass + base_url = pconfig.inference_base_url + catalog = fetch_github_model_catalog(api_key) + current_model = normalize_copilot_model_id( + current_model, + catalog=catalog, + api_key=api_key, + ) or current_model + else: + api_key = "" + for ev in pconfig.api_key_env_vars: + api_key = get_env_value(ev) or os.getenv(ev, "") + if api_key: + break + base_url_env = pconfig.base_url_env_var or "" + base_url = (get_env_value(base_url_env) if base_url_env else "") or pconfig.inference_base_url + catalog = None # Try live /models endpoint - live_models = fetch_api_models(api_key, base_url) + if is_copilot_catalog_provider and catalog: + live_models = [item.get("id", "") for item in catalog if item.get("id")] + else: + live_models = fetch_api_models(api_key, base_url) if live_models: provider_models = live_models print_info(f"Found {len(live_models)} model(s) from {pconfig.name} API") else: - provider_models = _DEFAULT_PROVIDER_MODELS.get(provider_id, []) + fallback_provider_id = "copilot" if provider_id == "copilot-acp" else provider_id + provider_models = _DEFAULT_PROVIDER_MODELS.get(fallback_provider_id, []) if provider_models: print_warning( f"Could not auto-detect models from {pconfig.name} API — showing defaults.\n" @@ -107,12 +210,29 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c keep_idx = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, keep_idx) + selected_model = current_model + if model_idx < len(provider_models): - _set_default_model(config, provider_models[model_idx]) + selected_model = provider_models[model_idx] + if is_copilot_catalog_provider: + selected_model = normalize_copilot_model_id( + selected_model, + catalog=catalog, + api_key=api_key, + ) or selected_model + _set_default_model(config, selected_model) elif model_idx == len(provider_models): custom = prompt_fn("Enter model name") if custom: - _set_default_model(config, custom) + if is_copilot_catalog_provider: + selected_model = normalize_copilot_model_id( + custom, + catalog=catalog, + api_key=api_key, + ) or custom + else: + selected_model = custom + _set_default_model(config, selected_model) else: # "Keep current" selected — validate it's compatible with the new # provider. OpenRouter-formatted names (containing "/") won't work @@ -123,8 +243,25 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c f"and won't work with {pconfig.name}. " f"Switching to {provider_models[0]}." ) + selected_model = provider_models[0] _set_default_model(config, provider_models[0]) + if provider_id == "copilot" and selected_model: + model_cfg = _model_config_dict(config) + model_cfg["api_mode"] = copilot_model_api_mode( + selected_model, + catalog=catalog, + api_key=api_key, + ) + config["model"] = model_cfg + _setup_copilot_reasoning_selection( + config, + selected_model, + prompt_choice, + catalog=catalog, + api_key=api_key, + ) + def _sync_model_from_disk(config: Dict[str, Any]) -> None: disk_model = load_config().get("model") @@ -673,6 +810,8 @@ def setup_model_provider(config: dict): resolve_codex_runtime_credentials, DEFAULT_CODEX_BASE_URL, detect_external_credentials, + get_auth_status, + resolve_api_key_provider_credentials, ) print_header("Inference Provider") @@ -682,6 +821,8 @@ def setup_model_provider(config: dict): existing_or = get_env_value("OPENROUTER_API_KEY") active_oauth = get_active_provider() existing_custom = get_env_value("OPENAI_BASE_URL") + copilot_status = get_auth_status("copilot") + copilot_acp_status = get_auth_status("copilot-acp") model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None @@ -702,7 +843,12 @@ def setup_model_provider(config: dict): # Detect if any provider is already configured has_any_provider = bool( - current_config_provider or active_oauth or existing_custom or existing_or + current_config_provider + or active_oauth + or existing_custom + or existing_or + or copilot_status.get("logged_in") + or copilot_acp_status.get("logged_in") ) # Build "keep current" label @@ -741,6 +887,8 @@ def setup_model_provider(config: dict): "Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)", "OpenCode Zen (35+ curated models, pay-as-you-go)", "OpenCode Go (open models, $10/month subscription)", + "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)", + "GitHub Copilot ACP (spawns `copilot --acp --stdio`)", ] if keep_label: provider_choices.append(keep_label) @@ -1412,7 +1560,56 @@ def setup_model_provider(config: dict): _set_model_provider(config, "opencode-go", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - # else: provider_idx == 14 (Keep current) — only shown when a provider already exists + elif provider_idx == 14: # GitHub Copilot + selected_provider = "copilot" + print() + print_header("GitHub Copilot") + pconfig = PROVIDER_REGISTRY["copilot"] + print_info("Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.") + print_info(f"Base URL: {pconfig.inference_base_url}") + print() + + copilot_creds = resolve_api_key_provider_credentials("copilot") + source = copilot_creds.get("source", "") + token = copilot_creds.get("api_key", "") + if token: + if source in ("GITHUB_TOKEN", "GH_TOKEN"): + print_info(f"Current: {token[:8]}... ({source})") + elif source == "gh auth token": + print_info("Current: authenticated via `gh auth token`") + else: + print_info("Current: GitHub token configured") + else: + api_key = prompt(" GitHub token", password=True) + if api_key: + save_env_value("GITHUB_TOKEN", api_key) + print_success("GitHub token saved") + else: + print_warning("Skipped - agent won't work without a GitHub token or gh auth login") + + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _set_model_provider(config, "copilot", pconfig.inference_base_url) + selected_base_url = pconfig.inference_base_url + + elif provider_idx == 15: # GitHub Copilot ACP + selected_provider = "copilot-acp" + print() + print_header("GitHub Copilot ACP") + pconfig = PROVIDER_REGISTRY["copilot-acp"] + print_info("Hermes will start `copilot --acp --stdio` for each request.") + print_info("Use HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH to override the command.") + print_info(f"Base marker: {pconfig.inference_base_url}") + print() + + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _set_model_provider(config, "copilot-acp", pconfig.inference_base_url) + selected_base_url = pconfig.inference_base_url + + # else: provider_idx == 16 (Keep current) — only shown when a provider already exists # Normalize "keep current" to an explicit provider so downstream logic # doesn't fall back to the generic OpenRouter/static-model path. if selected_provider is None: @@ -1444,6 +1641,8 @@ def setup_model_provider(config: dict): if _vision_needs_setup: _prov_names = { "nous-api": "Nous Portal API key", + "copilot": "GitHub Copilot", + "copilot-acp": "GitHub Copilot ACP", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", "minimax": "MiniMax", @@ -1583,7 +1782,15 @@ def setup_model_provider(config: dict): _set_default_model(config, custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) - elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ai-gateway"): + elif selected_provider == "copilot-acp": + _setup_provider_model_selection( + config, selected_provider, current_model, + prompt_choice, prompt, + ) + model_cfg = _model_config_dict(config) + model_cfg["api_mode"] = "chat_completions" + config["model"] = model_cfg + elif selected_provider in ("copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ai-gateway"): _setup_provider_model_selection( config, selected_provider, current_model, prompt_choice, prompt, @@ -1644,7 +1851,7 @@ def setup_model_provider(config: dict): # Write provider+base_url to config.yaml only after model selection is complete. # This prevents a race condition where the gateway picks up a new provider # before the model name has been updated to match. - if selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: + if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: _update_config_for_provider(selected_provider, selected_base_url) save_config(config) diff --git a/pyproject.toml b/pyproject.toml index 7e92f90781..79b8cdb95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.3.0" +version = "0.4.0" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" requires-python = ">=3.11" diff --git a/run_agent.py b/run_agent.py index 210ab2d2bb..348ec60d9b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -274,6 +274,10 @@ class AIAgent: api_key: str = None, provider: str = None, api_mode: str = None, + acp_command: str = None, + acp_args: list[str] | None = None, + command: str = None, + args: list[str] | None = None, model: str = "anthropic/claude-opus-4.6", # OpenRouter format max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) tool_delay: float = 1.0, @@ -379,6 +383,8 @@ class AIAgent: self.base_url = base_url or OPENROUTER_BASE_URL provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None self.provider = provider_name or "openrouter" + self.acp_command = acp_command or command + self.acp_args = list(acp_args or args or []) if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}: self.api_mode = api_mode elif self.provider == "openai-codex": @@ -572,6 +578,9 @@ class AIAgent: # Explicit credentials from CLI/gateway — construct directly. # The runtime provider resolver already handled auth for us. client_kwargs = {"api_key": api_key, "base_url": base_url} + if self.provider == "copilot-acp": + client_kwargs["command"] = self.acp_command + client_kwargs["args"] = self.acp_args effective_base = base_url if "openrouter" in effective_base.lower(): client_kwargs["default_headers"] = { @@ -579,6 +588,10 @@ class AIAgent: "X-OpenRouter-Title": "Hermes Agent", "X-OpenRouter-Categories": "productivity,cli-agent", } + elif "api.githubcopilot.com" in effective_base.lower(): + from hermes_cli.models import copilot_default_headers + + client_kwargs["default_headers"] = copilot_default_headers() elif "api.kimi.com" in effective_base.lower(): client_kwargs["default_headers"] = { "User-Agent": "KimiCLI/1.3", @@ -2685,10 +2698,23 @@ class AIAgent: if isinstance(client, Mock): return False + if bool(getattr(client, "is_closed", False)): + return True http_client = getattr(client, "_client", None) return bool(getattr(http_client, "is_closed", False)) def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: bool) -> Any: + if self.provider == "copilot-acp" or str(client_kwargs.get("base_url", "")).startswith("acp://copilot"): + from agent.copilot_acp_client import CopilotACPClient + + client = CopilotACPClient(**client_kwargs) + logger.info( + "Copilot ACP client created (%s, shared=%s) %s", + reason, + shared, + self._client_log_context(), + ) + return client client = OpenAI(**client_kwargs) logger.info( "OpenAI client created (%s, shared=%s) %s", @@ -3544,6 +3570,11 @@ class AIAgent: if not instructions: instructions = DEFAULT_AGENT_IDENTITY + is_github_responses = ( + "models.github.ai" in self.base_url.lower() + or "api.githubcopilot.com" in self.base_url.lower() + ) + # Resolve reasoning effort: config > default (medium) reasoning_effort = "medium" reasoning_enabled = True @@ -3561,13 +3592,23 @@ class AIAgent: "tool_choice": "auto", "parallel_tool_calls": True, "store": False, - "prompt_cache_key": self.session_id, } + if not is_github_responses: + kwargs["prompt_cache_key"] = self.session_id + if reasoning_enabled: - kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} - kwargs["include"] = ["reasoning.encrypted_content"] - else: + if is_github_responses: + # Copilot's Responses route advertises reasoning-effort support, + # but not OpenAI-specific prompt cache or encrypted reasoning + # fields. Keep the payload to the documented subset. + github_reasoning = self._github_models_reasoning_extra_body() + if github_reasoning is not None: + kwargs["reasoning"] = github_reasoning + else: + kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} + kwargs["include"] = ["reasoning.encrypted_content"] + elif not is_github_responses: kwargs["include"] = [] if self.max_tokens is not None: @@ -3638,6 +3679,10 @@ class AIAgent: extra_body = {} _is_openrouter = "openrouter" in self.base_url.lower() + _is_github_models = ( + "models.github.ai" in self.base_url.lower() + or "api.githubcopilot.com" in self.base_url.lower() + ) # Provider preferences (only, ignore, order, sort) are OpenRouter- # specific. Only send to OpenRouter-compatible endpoints. @@ -3648,19 +3693,24 @@ class AIAgent: _is_nous = "nousresearch" in self.base_url.lower() if self._supports_reasoning_extra_body(): - if self.reasoning_config is not None: - rc = dict(self.reasoning_config) - # Nous Portal requires reasoning enabled — don't send - # enabled=false to it (would cause 400). - if _is_nous and rc.get("enabled") is False: - pass # omit reasoning entirely for Nous when disabled - else: - extra_body["reasoning"] = rc + if _is_github_models: + github_reasoning = self._github_models_reasoning_extra_body() + if github_reasoning is not None: + extra_body["reasoning"] = github_reasoning else: - extra_body["reasoning"] = { - "enabled": True, - "effort": "medium" - } + if self.reasoning_config is not None: + rc = dict(self.reasoning_config) + # Nous Portal requires reasoning enabled — don't send + # enabled=false to it (would cause 400). + if _is_nous and rc.get("enabled") is False: + pass # omit reasoning entirely for Nous when disabled + else: + extra_body["reasoning"] = rc + else: + extra_body["reasoning"] = { + "enabled": True, + "effort": "medium" + } # Nous Portal product attribution if _is_nous: @@ -3683,6 +3733,13 @@ class AIAgent: return True if "ai-gateway.vercel.sh" in base_url: return True + if "models.github.ai" in base_url or "api.githubcopilot.com" in base_url: + try: + from hermes_cli.models import github_model_reasoning_efforts + + return bool(github_model_reasoning_efforts(self.model)) + except Exception: + return False if "openrouter" not in base_url: return False if "api.mistral.ai" in base_url: @@ -3699,6 +3756,38 @@ class AIAgent: ) return any(model.startswith(prefix) for prefix in reasoning_model_prefixes) + def _github_models_reasoning_extra_body(self) -> dict | None: + """Format reasoning payload for GitHub Models/OpenAI-compatible routes.""" + try: + from hermes_cli.models import github_model_reasoning_efforts + except Exception: + return None + + supported_efforts = github_model_reasoning_efforts(self.model) + if not supported_efforts: + return None + + if self.reasoning_config and isinstance(self.reasoning_config, dict): + if self.reasoning_config.get("enabled") is False: + return None + requested_effort = str( + self.reasoning_config.get("effort", "medium") + ).strip().lower() + else: + requested_effort = "medium" + + if requested_effort == "xhigh" and "high" in supported_efforts: + requested_effort = "high" + elif requested_effort not in supported_efforts: + if requested_effort == "minimal" and "low" in supported_efforts: + requested_effort = "low" + elif "medium" in supported_efforts: + requested_effort = "medium" + else: + requested_effort = supported_efforts[0] + + return {"effort": requested_effort} + def _build_assistant_message(self, assistant_message, finish_reason: str) -> dict: """Build a normalized assistant message dict from an API response message. diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 760fd5845e..0a396944ad 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -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 ( diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 59574c743a..5f26e401f6 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -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 ----------------------------------------------- diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 671bb9ba38..228d152403 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -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)) diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index 98f27d1031..631a7051ce 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -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 diff --git a/tests/test_model_provider_persistence.py b/tests/test_model_provider_persistence.py index 026715bf28..d408a573a5 100644 --- a/tests/test_model_provider_persistence.py +++ b/tests/test_model_provider_persistence.py @@ -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" diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index cfe8bab208..daa5f4a3a7 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -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.""" diff --git a/tests/test_run_agent_codex_responses.py b/tests/test_run_agent_codex_responses.py index 715074d90c..42e41ec7ba 100644 --- a/tests/test_run_agent_codex_responses.py +++ b/tests/test_run_agent_codex_responses.py @@ -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} diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2a0e5b1312..1d8ed9c041 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -205,6 +205,8 @@ def _build_child_agent( effective_base_url = override_base_url or parent_agent.base_url effective_api_key = override_api_key or parent_api_key effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None) + effective_acp_command = getattr(parent_agent, "acp_command", None) + effective_acp_args = list(getattr(parent_agent, "acp_args", []) or []) child = AIAgent( base_url=effective_base_url, @@ -212,6 +214,8 @@ def _build_child_agent( model=effective_model, provider=effective_provider, api_mode=effective_api_mode, + acp_command=effective_acp_command, + acp_args=effective_acp_args, max_iterations=max_iterations, max_tokens=getattr(parent_agent, "max_tokens", None), reasoning_config=getattr(parent_agent, "reasoning_config", None), @@ -232,6 +236,7 @@ def _build_child_agent( tool_progress_callback=child_progress_cb, iteration_budget=shared_budget, ) + child._delegate_saved_tool_names = list(_saved_tool_names) # Set delegation depth so children can't spawn grandchildren child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 @@ -372,7 +377,11 @@ def _run_single_child( finally: # Restore the parent's tool names so the process-global is correct # for any subsequent execute_code calls or other consumers. - model_tools._last_resolved_tool_names = _saved_tool_names + import model_tools + + saved_tool_names = getattr(child, "_delegate_saved_tool_names", None) + if isinstance(saved_tool_names, list): + model_tools._last_resolved_tool_names = list(saved_tool_names) # Unregister child from interrupt propagation if hasattr(parent_agent, '_active_children'): @@ -623,6 +632,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), } diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index d3f9a0ce34..effb13e5b2 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -66,7 +66,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | | `--resume ` / `--continue [name]` | Resume a session directly from `chat`. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index c7ddfd1fa5..3edf636ca3 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -48,7 +48,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode`, `alibaba` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode`, `alibaba` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 8ee4d30951..8f8a71217e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -63,6 +63,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro |----------|-------| | **Nous Portal** | `hermes model` (OAuth, subscription-based) | | **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) | +| **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) | +| **GitHub Copilot** | `hermes model` (uses `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) | | **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | | **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) | @@ -122,6 +124,15 @@ model: These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select: ```bash +# GitHub Copilot ACP agent backend +hermes chat --provider copilot-acp --model copilot-acp +# Requires the GitHub Copilot CLI in PATH and an existing `copilot login` +# session. Hermes starts `copilot --acp --stdio` for each request. + +# GitHub Copilot +hermes chat --provider copilot --model gpt-5.4 +# Uses: GITHUB_TOKEN, GH_TOKEN, or `gh auth token` + # z.ai / ZhipuAI GLM hermes chat --provider zai --model glm-4-plus # Requires: GLM_API_KEY in ~/.hermes/.env @@ -146,11 +157,19 @@ hermes chat --provider alibaba --model qwen-plus Or set the provider permanently in `config.yaml`: ```yaml model: - provider: "zai" # or: kimi-coding, minimax, minimax-cn, alibaba - default: "glm-4-plus" + provider: "copilot-acp" # or: copilot, zai, kimi-coding, minimax, minimax-cn, alibaba + default: "copilot-acp" ``` -Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, or `DASHSCOPE_BASE_URL` environment variables. +Or, for the direct Copilot premium API provider: + +```yaml +model: + provider: "copilot" + default: "gpt-5.4" +``` + +Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, or `DASHSCOPE_BASE_URL` environment variables. The Copilot premium API provider uses the built-in GitHub Copilot API base URL automatically. The Copilot ACP backend can be pointed at a different executable with `HERMES_COPILOT_ACP_COMMAND`, `COPILOT_CLI_PATH`, and `HERMES_COPILOT_ACP_ARGS`. ## Custom & Self-Hosted LLM Providers @@ -443,7 +462,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session. -Supported providers: `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `custom`. +Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `custom`. :::tip Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers). @@ -766,7 +785,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers: `auto`, `openrouter`, `nous`, `codex`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, and any provider registered in the [provider registry](/docs/reference/environment-variables). +Available providers: `auto`, `openrouter`, `nous`, `codex`, `copilot`, `anthropic`, `main`, `zai`, `kimi-coding`, `minimax`, and any provider registered in the [provider registry](/docs/reference/environment-variables). ### Full auxiliary config reference @@ -1224,7 +1243,7 @@ delegation: **Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only. -The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed. +The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed. **Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).