From 0c392e7a8743f8c29d5c7a2332e9a67e457a1364 Mon Sep 17 00:00:00 2001 From: max <> Date: Tue, 17 Mar 2026 23:40:22 -0700 Subject: [PATCH] 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 8590a62e4..0f5b2428e 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 94be9d6fe..22b967fd2 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 000000000..7b8f45d9c --- /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 249548701..d57cd1b83 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 19a0c972f..7df55a92c 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 2060bf2fb..ea7ff0e9b 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 ea9f2a283..4e9666a90 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 90f082720..eea32d6db 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 54573acf1..f73506371 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 d5d4885a7..a578c4d7d 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 174aa9475..e6f4bc5d5 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 34ae43be8..ae3948da5 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 e3b5ed7d4..3264d7e47 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 7e92f9078..79b8cdb95 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 210ab2d2b..348ec60d9 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 760fd5845..0a396944a 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 59574c743..5f26e401f 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 671bb9ba3..228d15240 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 98f27d103..631a7051c 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 026715bf2..d408a573a 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 cfe8bab20..daa5f4a3a 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 715074d90..42e41ec7b 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 2a0e5b131..1d8ed9c04 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 d3f9a0ce3..effb13e5b 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 c7ddfd1fa..3edf636ca 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 8ee4d3095..8f8a71217 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).