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).