feat: GitHub Copilot provider integration (#1924)
feat: GitHub Copilot provider integration with OAuth auth, API routing, and docs
This commit is contained in:
commit
1fa3737134
28 changed files with 3130 additions and 119 deletions
|
|
@ -194,6 +194,8 @@ class SessionManager:
|
||||||
"api_mode": runtime.get("api_mode"),
|
"api_mode": runtime.get("api_mode"),
|
||||||
"base_url": runtime.get("base_url"),
|
"base_url": runtime.get("base_url"),
|
||||||
"api_key": runtime.get("api_key"),
|
"api_key": runtime.get("api_key"),
|
||||||
|
"command": runtime.get("command"),
|
||||||
|
"args": list(runtime.get("args") or []),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -480,11 +480,11 @@ def _read_codex_access_token() -> Optional[str]:
|
||||||
def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||||
"""Try each API-key provider in PROVIDER_REGISTRY order.
|
"""Try each API-key provider in PROVIDER_REGISTRY order.
|
||||||
|
|
||||||
Returns (client, model) for the first provider whose env var is set,
|
Returns (client, model) for the first provider with usable runtime
|
||||||
or (None, None) if none are configured.
|
credentials, or (None, None) if none are configured.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("Could not import PROVIDER_REGISTRY for API-key fallback")
|
logger.debug("Could not import PROVIDER_REGISTRY for API-key fallback")
|
||||||
return None, None
|
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():
|
for provider_id, pconfig in PROVIDER_REGISTRY.items():
|
||||||
if pconfig.auth_type != "api_key":
|
if pconfig.auth_type != "api_key":
|
||||||
continue
|
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":
|
if provider_id == "anthropic":
|
||||||
return _try_anthropic()
|
return _try_anthropic()
|
||||||
|
|
||||||
# Resolve base URL (with optional env-var override)
|
creds = resolve_api_key_provider_credentials(provider_id)
|
||||||
# Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1
|
api_key = str(creds.get("api_key", "")).strip()
|
||||||
env_url = ""
|
if not api_key:
|
||||||
if pconfig.base_url_env_var:
|
continue
|
||||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
|
||||||
if env_url:
|
base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_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
|
|
||||||
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
|
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
|
||||||
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
|
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
|
||||||
extra = {}
|
extra = {}
|
||||||
if "api.kimi.com" in base_url.lower():
|
if "api.kimi.com" in base_url.lower():
|
||||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
|
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 OpenAI(api_key=api_key, base_url=base_url, **extra), model
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -744,6 +734,10 @@ def _to_async_client(sync_client, model: str):
|
||||||
base_lower = str(sync_client.base_url).lower()
|
base_lower = str(sync_client.base_url).lower()
|
||||||
if "openrouter" in base_lower:
|
if "openrouter" in base_lower:
|
||||||
async_kwargs["default_headers"] = dict(_OR_HEADERS)
|
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:
|
elif "api.kimi.com" in base_lower:
|
||||||
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
|
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
|
||||||
return AsyncOpenAI(**async_kwargs), model
|
return AsyncOpenAI(**async_kwargs), model
|
||||||
|
|
@ -885,7 +879,7 @@ def resolve_provider_client(
|
||||||
|
|
||||||
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
|
||||||
try:
|
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:
|
except ImportError:
|
||||||
logger.debug("hermes_cli.auth not available for provider %s", provider)
|
logger.debug("hermes_cli.auth not available for provider %s", provider)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -904,26 +898,18 @@ def resolve_provider_client(
|
||||||
final_model = model or default_model
|
final_model = model or default_model
|
||||||
return (_to_async_client(client, final_model) if async_mode else (client, final_model))
|
return (_to_async_client(client, final_model) if async_mode else (client, final_model))
|
||||||
|
|
||||||
# Find the first configured API key
|
creds = resolve_api_key_provider_credentials(provider)
|
||||||
api_key = ""
|
api_key = str(creds.get("api_key", "")).strip()
|
||||||
for env_var in pconfig.api_key_env_vars:
|
|
||||||
api_key = os.getenv(env_var, "").strip()
|
|
||||||
if api_key:
|
|
||||||
break
|
|
||||||
if not api_key:
|
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 "
|
logger.warning("resolve_provider_client: provider %s has no API "
|
||||||
"key configured (tried: %s)",
|
"key configured (tried: %s)",
|
||||||
provider, ", ".join(pconfig.api_key_env_vars))
|
provider, ", ".join(tried_sources))
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Resolve base URL (env override → provider-specific logic → default)
|
base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||||
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
|
|
||||||
|
|
||||||
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
||||||
final_model = model or default_model
|
final_model = model or default_model
|
||||||
|
|
@ -932,6 +918,10 @@ def resolve_provider_client(
|
||||||
headers = {}
|
headers = {}
|
||||||
if "api.kimi.com" in base_url.lower():
|
if "api.kimi.com" in base_url.lower():
|
||||||
headers["User-Agent"] = "KimiCLI/1.0"
|
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,
|
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||||
**({"default_headers": headers} if headers else {}))
|
**({"default_headers": headers} if headers else {}))
|
||||||
|
|
|
||||||
447
agent/copilot_acp_client.py
Normal file
447
agent/copilot_acp_client.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -125,6 +125,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
|
||||||
"base_url": primary.get("base_url"),
|
"base_url": primary.get("base_url"),
|
||||||
"provider": primary.get("provider"),
|
"provider": primary.get("provider"),
|
||||||
"api_mode": primary.get("api_mode"),
|
"api_mode": primary.get("api_mode"),
|
||||||
|
"command": primary.get("command"),
|
||||||
|
"args": list(primary.get("args") or []),
|
||||||
},
|
},
|
||||||
"label": None,
|
"label": None,
|
||||||
"signature": (
|
"signature": (
|
||||||
|
|
@ -132,6 +134,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
|
||||||
primary.get("provider"),
|
primary.get("provider"),
|
||||||
primary.get("base_url"),
|
primary.get("base_url"),
|
||||||
primary.get("api_mode"),
|
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"),
|
"base_url": primary.get("base_url"),
|
||||||
"provider": primary.get("provider"),
|
"provider": primary.get("provider"),
|
||||||
"api_mode": primary.get("api_mode"),
|
"api_mode": primary.get("api_mode"),
|
||||||
|
"command": primary.get("command"),
|
||||||
|
"args": list(primary.get("args") or []),
|
||||||
},
|
},
|
||||||
"label": None,
|
"label": None,
|
||||||
"signature": (
|
"signature": (
|
||||||
|
|
@ -163,6 +169,8 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
|
||||||
primary.get("provider"),
|
primary.get("provider"),
|
||||||
primary.get("base_url"),
|
primary.get("base_url"),
|
||||||
primary.get("api_mode"),
|
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"),
|
"base_url": runtime.get("base_url"),
|
||||||
"provider": runtime.get("provider"),
|
"provider": runtime.get("provider"),
|
||||||
"api_mode": runtime.get("api_mode"),
|
"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')})",
|
"label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
|
||||||
"signature": (
|
"signature": (
|
||||||
|
|
@ -180,5 +190,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
|
||||||
runtime.get("provider"),
|
runtime.get("provider"),
|
||||||
runtime.get("base_url"),
|
runtime.get("base_url"),
|
||||||
runtime.get("api_mode"),
|
runtime.get("api_mode"),
|
||||||
|
runtime.get("command"),
|
||||||
|
tuple(runtime.get("args") or ()),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
cli.py
62
cli.py
|
|
@ -1069,6 +1069,8 @@ class HermesCLI:
|
||||||
self._provider_source: Optional[str] = None
|
self._provider_source: Optional[str] = None
|
||||||
self.provider = self.requested_provider
|
self.provider = self.requested_provider
|
||||||
self.api_mode = "chat_completions"
|
self.api_mode = "chat_completions"
|
||||||
|
self.acp_command: Optional[str] = None
|
||||||
|
self.acp_args: list[str] = []
|
||||||
self.base_url = (
|
self.base_url = (
|
||||||
base_url
|
base_url
|
||||||
or os.getenv("OPENAI_BASE_URL")
|
or os.getenv("OPENAI_BASE_URL")
|
||||||
|
|
@ -1385,27 +1387,35 @@ class HermesCLI:
|
||||||
return [("class:status-bar", f" {self._build_status_bar_text()} ")]
|
return [("class:status-bar", f" {self._build_status_bar_text()} ")]
|
||||||
|
|
||||||
def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
|
def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
|
||||||
"""Strip provider prefixes and swap the default model for Codex.
|
"""Normalize provider-specific model IDs and routing."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
current_model = (self.model or "").strip()
|
current_model = (self.model or "").strip()
|
||||||
changed = False
|
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")
|
# 1. Strip provider prefix ("openai/gpt-5.4" → "gpt-5.4")
|
||||||
if "/" in current_model:
|
if "/" in current_model:
|
||||||
slug = current_model.split("/", 1)[1]
|
slug = current_model.split("/", 1)[1]
|
||||||
|
|
@ -1681,6 +1691,8 @@ class HermesCLI:
|
||||||
base_url = runtime.get("base_url")
|
base_url = runtime.get("base_url")
|
||||||
resolved_provider = runtime.get("provider", "openrouter")
|
resolved_provider = runtime.get("provider", "openrouter")
|
||||||
resolved_api_mode = runtime.get("api_mode", self.api_mode)
|
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:
|
if not isinstance(api_key, str) or not api_key:
|
||||||
self.console.print("[bold red]Provider resolver returned an empty API key.[/]")
|
self.console.print("[bold red]Provider resolver returned an empty API key.[/]")
|
||||||
return False
|
return False
|
||||||
|
|
@ -1692,9 +1704,13 @@ class HermesCLI:
|
||||||
routing_changed = (
|
routing_changed = (
|
||||||
resolved_provider != self.provider
|
resolved_provider != self.provider
|
||||||
or resolved_api_mode != self.api_mode
|
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.provider = resolved_provider
|
||||||
self.api_mode = resolved_api_mode
|
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._provider_source = runtime.get("source")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
|
|
@ -1724,6 +1740,8 @@ class HermesCLI:
|
||||||
"base_url": self.base_url,
|
"base_url": self.base_url,
|
||||||
"provider": self.provider,
|
"provider": self.provider,
|
||||||
"api_mode": self.api_mode,
|
"api_mode": self.api_mode,
|
||||||
|
"command": self.acp_command,
|
||||||
|
"args": list(self.acp_args or []),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1792,6 +1810,8 @@ class HermesCLI:
|
||||||
"base_url": self.base_url,
|
"base_url": self.base_url,
|
||||||
"provider": self.provider,
|
"provider": self.provider,
|
||||||
"api_mode": self.api_mode,
|
"api_mode": self.api_mode,
|
||||||
|
"command": self.acp_command,
|
||||||
|
"args": list(self.acp_args or []),
|
||||||
}
|
}
|
||||||
effective_model = model_override or self.model
|
effective_model = model_override or self.model
|
||||||
self.agent = AIAgent(
|
self.agent = AIAgent(
|
||||||
|
|
@ -1800,6 +1820,8 @@ class HermesCLI:
|
||||||
base_url=runtime.get("base_url"),
|
base_url=runtime.get("base_url"),
|
||||||
provider=runtime.get("provider"),
|
provider=runtime.get("provider"),
|
||||||
api_mode=runtime.get("api_mode"),
|
api_mode=runtime.get("api_mode"),
|
||||||
|
acp_command=runtime.get("command"),
|
||||||
|
acp_args=runtime.get("args"),
|
||||||
max_iterations=self.max_turns,
|
max_iterations=self.max_turns,
|
||||||
enabled_toolsets=self.enabled_toolsets,
|
enabled_toolsets=self.enabled_toolsets,
|
||||||
verbose_logging=self.verbose,
|
verbose_logging=self.verbose,
|
||||||
|
|
@ -1836,6 +1858,8 @@ class HermesCLI:
|
||||||
runtime.get("provider"),
|
runtime.get("provider"),
|
||||||
runtime.get("base_url"),
|
runtime.get("base_url"),
|
||||||
runtime.get("api_mode"),
|
runtime.get("api_mode"),
|
||||||
|
runtime.get("command"),
|
||||||
|
tuple(runtime.get("args") or ()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._pending_title and self._session_db:
|
if self._pending_title and self._session_db:
|
||||||
|
|
@ -3765,6 +3789,8 @@ class HermesCLI:
|
||||||
base_url=turn_route["runtime"].get("base_url"),
|
base_url=turn_route["runtime"].get("base_url"),
|
||||||
provider=turn_route["runtime"].get("provider"),
|
provider=turn_route["runtime"].get("provider"),
|
||||||
api_mode=turn_route["runtime"].get("api_mode"),
|
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,
|
max_iterations=self.max_turns,
|
||||||
enabled_toolsets=self.enabled_toolsets,
|
enabled_toolsets=self.enabled_toolsets,
|
||||||
quiet_mode=True,
|
quiet_mode=True,
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||||
"base_url": runtime.get("base_url"),
|
"base_url": runtime.get("base_url"),
|
||||||
"provider": runtime.get("provider"),
|
"provider": runtime.get("provider"),
|
||||||
"api_mode": runtime.get("api_mode"),
|
"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"),
|
base_url=turn_route["runtime"].get("base_url"),
|
||||||
provider=turn_route["runtime"].get("provider"),
|
provider=turn_route["runtime"].get("provider"),
|
||||||
api_mode=turn_route["runtime"].get("api_mode"),
|
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,
|
max_iterations=max_iterations,
|
||||||
reasoning_config=reasoning_config,
|
reasoning_config=reasoning_config,
|
||||||
prefill_messages=prefill_messages,
|
prefill_messages=prefill_messages,
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,8 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||||
"base_url": runtime.get("base_url"),
|
"base_url": runtime.get("base_url"),
|
||||||
"provider": runtime.get("provider"),
|
"provider": runtime.get("provider"),
|
||||||
"api_mode": runtime.get("api_mode"),
|
"api_mode": runtime.get("api_mode"),
|
||||||
|
"command": runtime.get("command"),
|
||||||
|
"args": list(runtime.get("args") or []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -611,6 +613,8 @@ class GatewayRunner:
|
||||||
"base_url": runtime_kwargs.get("base_url"),
|
"base_url": runtime_kwargs.get("base_url"),
|
||||||
"provider": runtime_kwargs.get("provider"),
|
"provider": runtime_kwargs.get("provider"),
|
||||||
"api_mode": runtime_kwargs.get("api_mode"),
|
"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)
|
return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,5 @@ Provides subcommands for:
|
||||||
- hermes cron - Manage cron jobs
|
- hermes cron - Manage cron jobs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.4.0"
|
||||||
__release_date__ = "2026.3.17"
|
__release_date__ = "2026.3.18"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import shlex
|
||||||
import stat
|
import stat
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
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
|
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
||||||
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
||||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
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_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||||
|
|
@ -108,6 +111,20 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||||
auth_type="oauth_external",
|
auth_type="oauth_external",
|
||||||
inference_base_url=DEFAULT_CODEX_BASE_URL,
|
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=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_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(
|
"zai": ProviderConfig(
|
||||||
id="zai",
|
id="zai",
|
||||||
name="Z.AI / GLM",
|
name="Z.AI / GLM",
|
||||||
|
|
@ -222,6 +239,70 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
|
||||||
return default_url
|
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."""
|
||||||
|
if provider_id == "copilot":
|
||||||
|
# Use the dedicated copilot auth module for proper token validation
|
||||||
|
try:
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
if token:
|
||||||
|
return token, source
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("Copilot token validation failed: %s", exc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
for env_var in pconfig.api_key_env_vars:
|
||||||
|
val = os.getenv(env_var, "").strip()
|
||||||
|
if val:
|
||||||
|
return val, env_var
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Z.AI Endpoint Detection
|
# Z.AI Endpoint Detection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -572,6 +653,9 @@ def resolve_provider(
|
||||||
"kimi": "kimi-coding", "moonshot": "kimi-coding",
|
"kimi": "kimi-coding", "moonshot": "kimi-coding",
|
||||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||||
"claude": "anthropic", "claude-code": "anthropic",
|
"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",
|
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||||||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
"opencode": "opencode-zen", "zen": "opencode-zen",
|
||||||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||||||
|
|
@ -611,6 +695,11 @@ def resolve_provider(
|
||||||
for pid, pconfig in PROVIDER_REGISTRY.items():
|
for pid, pconfig in PROVIDER_REGISTRY.items():
|
||||||
if pconfig.auth_type != "api_key":
|
if pconfig.auth_type != "api_key":
|
||||||
continue
|
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:
|
for env_var in pconfig.api_key_env_vars:
|
||||||
if os.getenv(env_var, "").strip():
|
if os.getenv(env_var, "").strip():
|
||||||
return pid
|
return pid
|
||||||
|
|
@ -1479,12 +1568,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
api_key = ""
|
api_key = ""
|
||||||
key_source = ""
|
key_source = ""
|
||||||
for env_var in pconfig.api_key_env_vars:
|
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
|
||||||
val = os.getenv(env_var, "").strip()
|
|
||||||
if val:
|
|
||||||
api_key = val
|
|
||||||
key_source = env_var
|
|
||||||
break
|
|
||||||
|
|
||||||
env_url = ""
|
env_url = ""
|
||||||
if pconfig.base_url_env_var:
|
if pconfig.base_url_env_var:
|
||||||
|
|
@ -1507,6 +1591,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]:
|
def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Generic auth status dispatcher."""
|
"""Generic auth status dispatcher."""
|
||||||
target = provider_id or get_active_provider()
|
target = provider_id or get_active_provider()
|
||||||
|
|
@ -1514,6 +1628,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
return get_nous_auth_status()
|
return get_nous_auth_status()
|
||||||
if target == "openai-codex":
|
if target == "openai-codex":
|
||||||
return get_codex_auth_status()
|
return get_codex_auth_status()
|
||||||
|
if target == "copilot-acp":
|
||||||
|
return get_external_process_provider_status(target)
|
||||||
# API-key providers
|
# API-key providers
|
||||||
pconfig = PROVIDER_REGISTRY.get(target)
|
pconfig = PROVIDER_REGISTRY.get(target)
|
||||||
if pconfig and pconfig.auth_type == "api_key":
|
if pconfig and pconfig.auth_type == "api_key":
|
||||||
|
|
@ -1536,12 +1652,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
api_key = ""
|
api_key = ""
|
||||||
key_source = ""
|
key_source = ""
|
||||||
for env_var in pconfig.api_key_env_vars:
|
api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
|
||||||
val = os.getenv(env_var, "").strip()
|
|
||||||
if val:
|
|
||||||
api_key = val
|
|
||||||
key_source = env_var
|
|
||||||
break
|
|
||||||
|
|
||||||
env_url = ""
|
env_url = ""
|
||||||
if pconfig.base_url_env_var:
|
if pconfig.base_url_env_var:
|
||||||
|
|
@ -1562,6 +1673,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
|
# External credential detection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
295
hermes_cli/copilot_auth.py
Normal file
295
hermes_cli/copilot_auth.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"""GitHub Copilot authentication utilities.
|
||||||
|
|
||||||
|
Implements the OAuth device code flow used by the Copilot CLI and handles
|
||||||
|
token validation/exchange for the Copilot API.
|
||||||
|
|
||||||
|
Token type support (per GitHub docs):
|
||||||
|
gho_ OAuth token ✓ (default via copilot login)
|
||||||
|
github_pat_ Fine-grained PAT ✓ (needs Copilot Requests permission)
|
||||||
|
ghu_ GitHub App token ✓ (via environment variable)
|
||||||
|
ghp_ Classic PAT ✗ NOT SUPPORTED
|
||||||
|
|
||||||
|
Credential search order (matching Copilot CLI behaviour):
|
||||||
|
1. COPILOT_GITHUB_TOKEN env var
|
||||||
|
2. GH_TOKEN env var
|
||||||
|
3. GITHUB_TOKEN env var
|
||||||
|
4. gh auth token CLI fallback
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
|
||||||
|
COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||||
|
COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||||
|
COPILOT_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||||
|
|
||||||
|
# Copilot API constants
|
||||||
|
COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||||
|
COPILOT_API_BASE_URL = "https://api.githubcopilot.com"
|
||||||
|
|
||||||
|
# Token type prefixes
|
||||||
|
_CLASSIC_PAT_PREFIX = "ghp_"
|
||||||
|
_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
|
||||||
|
|
||||||
|
# Env var search order (matches Copilot CLI)
|
||||||
|
COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||||
|
|
||||||
|
# Polling constants
|
||||||
|
_DEVICE_CODE_POLL_INTERVAL = 5 # seconds
|
||||||
|
_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def is_classic_pat(token: str) -> bool:
|
||||||
|
"""Check if a token is a classic PAT (ghp_*), which Copilot doesn't support."""
|
||||||
|
return token.strip().startswith(_CLASSIC_PAT_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_copilot_token(token: str) -> tuple[bool, str]:
|
||||||
|
"""Validate that a token is usable with the Copilot API.
|
||||||
|
|
||||||
|
Returns (valid, message).
|
||||||
|
"""
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
return False, "Empty token"
|
||||||
|
|
||||||
|
if token.startswith(_CLASSIC_PAT_PREFIX):
|
||||||
|
return False, (
|
||||||
|
"Classic Personal Access Tokens (ghp_*) are not supported by the "
|
||||||
|
"Copilot API. Use one of:\n"
|
||||||
|
" → `copilot login` or `hermes model` to authenticate via OAuth\n"
|
||||||
|
" → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n"
|
||||||
|
" → `gh auth login` with the default device code flow (produces gho_* tokens)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_copilot_token() -> tuple[str, str]:
|
||||||
|
"""Resolve a GitHub token suitable for Copilot API use.
|
||||||
|
|
||||||
|
Returns (token, source) where source describes where the token came from.
|
||||||
|
Raises ValueError if only a classic PAT is available.
|
||||||
|
"""
|
||||||
|
# 1. Check env vars in priority order
|
||||||
|
for env_var in COPILOT_ENV_VARS:
|
||||||
|
val = os.getenv(env_var, "").strip()
|
||||||
|
if val:
|
||||||
|
valid, msg = validate_copilot_token(val)
|
||||||
|
if not valid:
|
||||||
|
logger.warning(
|
||||||
|
"Token from %s is not supported: %s", env_var, msg
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return val, env_var
|
||||||
|
|
||||||
|
# 2. Fall back to gh auth token
|
||||||
|
token = _try_gh_cli_token()
|
||||||
|
if token:
|
||||||
|
valid, msg = validate_copilot_token(token)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(
|
||||||
|
f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}"
|
||||||
|
)
|
||||||
|
return token, "gh auth token"
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ─── OAuth Device Code Flow ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def copilot_device_code_login(
|
||||||
|
*,
|
||||||
|
host: str = "github.com",
|
||||||
|
timeout_seconds: float = 300,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Run the GitHub OAuth device code flow for Copilot.
|
||||||
|
|
||||||
|
Prints instructions for the user, polls for completion, and returns
|
||||||
|
the OAuth access token on success, or None on failure/cancellation.
|
||||||
|
|
||||||
|
This replicates the flow used by opencode and the Copilot CLI.
|
||||||
|
"""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
domain = host.rstrip("/")
|
||||||
|
device_code_url = f"https://{domain}/login/device/code"
|
||||||
|
access_token_url = f"https://{domain}/login/oauth/access_token"
|
||||||
|
|
||||||
|
# Step 1: Request device code
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"client_id": COPILOT_OAUTH_CLIENT_ID,
|
||||||
|
"scope": "read:user",
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
device_code_url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": "HermesAgent/1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
device_data = json.loads(resp.read().decode())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to initiate device authorization: %s", exc)
|
||||||
|
print(f" ✗ Failed to start device authorization: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
verification_uri = device_data.get("verification_uri", "https://github.com/login/device")
|
||||||
|
user_code = device_data.get("user_code", "")
|
||||||
|
device_code = device_data.get("device_code", "")
|
||||||
|
interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1)
|
||||||
|
|
||||||
|
if not device_code or not user_code:
|
||||||
|
print(" ✗ GitHub did not return a device code.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: Show instructions
|
||||||
|
print()
|
||||||
|
print(f" Open this URL in your browser: {verification_uri}")
|
||||||
|
print(f" Enter this code: {user_code}")
|
||||||
|
print()
|
||||||
|
print(" Waiting for authorization...", end="", flush=True)
|
||||||
|
|
||||||
|
# Step 3: Poll for completion
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
|
||||||
|
|
||||||
|
poll_data = urllib.parse.urlencode({
|
||||||
|
"client_id": COPILOT_OAUTH_CLIENT_ID,
|
||||||
|
"device_code": device_code,
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
poll_req = urllib.request.Request(
|
||||||
|
access_token_url,
|
||||||
|
data=poll_data,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": "HermesAgent/1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(poll_req, timeout=10) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
print(".", end="", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.get("access_token"):
|
||||||
|
print(" ✓")
|
||||||
|
return result["access_token"]
|
||||||
|
|
||||||
|
error = result.get("error", "")
|
||||||
|
if error == "authorization_pending":
|
||||||
|
print(".", end="", flush=True)
|
||||||
|
continue
|
||||||
|
elif error == "slow_down":
|
||||||
|
# RFC 8628: add 5 seconds to polling interval
|
||||||
|
server_interval = result.get("interval")
|
||||||
|
if isinstance(server_interval, (int, float)) and server_interval > 0:
|
||||||
|
interval = int(server_interval)
|
||||||
|
else:
|
||||||
|
interval += 5
|
||||||
|
print(".", end="", flush=True)
|
||||||
|
continue
|
||||||
|
elif error == "expired_token":
|
||||||
|
print()
|
||||||
|
print(" ✗ Device code expired. Please try again.")
|
||||||
|
return None
|
||||||
|
elif error == "access_denied":
|
||||||
|
print()
|
||||||
|
print(" ✗ Authorization was denied.")
|
||||||
|
return None
|
||||||
|
elif error:
|
||||||
|
print()
|
||||||
|
print(f" ✗ Authorization failed: {error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ✗ Timed out waiting for authorization.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Copilot API Headers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def copilot_request_headers(
|
||||||
|
*,
|
||||||
|
is_agent_turn: bool = True,
|
||||||
|
is_vision: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build the standard headers for Copilot API requests.
|
||||||
|
|
||||||
|
Replicates the header set used by opencode and the Copilot CLI.
|
||||||
|
"""
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"Editor-Version": "vscode/1.104.1",
|
||||||
|
"User-Agent": "HermesAgent/1.0",
|
||||||
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"x-initiator": "agent" if is_agent_turn else "user",
|
||||||
|
}
|
||||||
|
if is_vision:
|
||||||
|
headers["Copilot-Vision-Request"] = "true"
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
@ -125,6 +125,17 @@ def _has_any_provider_configured() -> bool:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Check for Nous Portal OAuth credentials
|
||||||
auth_file = get_hermes_home() / "auth.json"
|
auth_file = get_hermes_home() / "auth.json"
|
||||||
if auth_file.exists():
|
if auth_file.exists():
|
||||||
|
|
@ -775,6 +786,8 @@ def cmd_model(args):
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"nous": "Nous Portal",
|
"nous": "Nous Portal",
|
||||||
"openai-codex": "OpenAI Codex",
|
"openai-codex": "OpenAI Codex",
|
||||||
|
"copilot-acp": "GitHub Copilot ACP",
|
||||||
|
"copilot": "GitHub Copilot",
|
||||||
"anthropic": "Anthropic",
|
"anthropic": "Anthropic",
|
||||||
"zai": "Z.AI / GLM",
|
"zai": "Z.AI / GLM",
|
||||||
"kimi-coding": "Kimi / Moonshot",
|
"kimi-coding": "Kimi / Moonshot",
|
||||||
|
|
@ -799,6 +812,8 @@ def cmd_model(args):
|
||||||
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||||
("nous", "Nous Portal (Nous Research subscription)"),
|
("nous", "Nous Portal (Nous Research subscription)"),
|
||||||
("openai-codex", "OpenAI Codex"),
|
("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)"),
|
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||||
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||||
("kimi-coding", "Kimi / Moonshot (Moonshot 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)
|
_model_flow_nous(config, current_model)
|
||||||
elif selected_provider == "openai-codex":
|
elif selected_provider == "openai-codex":
|
||||||
_model_flow_openai_codex(config, current_model)
|
_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":
|
elif selected_provider == "custom":
|
||||||
_model_flow_custom(config)
|
_model_flow_custom(config)
|
||||||
elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map:
|
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
|
# Curated model lists for direct API-key providers
|
||||||
_PROVIDER_MODELS = {
|
_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": [
|
"zai": [
|
||||||
"glm-5",
|
"glm-5",
|
||||||
"glm-4.7",
|
"glm-4.7",
|
||||||
|
|
@ -1447,6 +1485,376 @@ _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, gh CLI, or OAuth device code."""
|
||||||
|
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()
|
||||||
|
print(" Supported token types:")
|
||||||
|
print(" → OAuth token (gho_*) via `copilot login` or device code flow")
|
||||||
|
print(" → Fine-grained PAT (github_pat_*) with Copilot Requests permission")
|
||||||
|
print(" → GitHub App token (ghu_*) via environment variable")
|
||||||
|
print(" ✗ Classic PAT (ghp_*) NOT supported by Copilot API")
|
||||||
|
print()
|
||||||
|
print(" Options:")
|
||||||
|
print(" 1. Login with GitHub (OAuth device code flow)")
|
||||||
|
print(" 2. Enter a token manually")
|
||||||
|
print(" 3. Cancel")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
choice = input(" Choice [1-3]: ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
try:
|
||||||
|
from hermes_cli.copilot_auth import copilot_device_code_login
|
||||||
|
token = copilot_device_code_login()
|
||||||
|
if token:
|
||||||
|
save_env_value("COPILOT_GITHUB_TOKEN", token)
|
||||||
|
print(" Copilot token saved.")
|
||||||
|
print()
|
||||||
|
else:
|
||||||
|
print(" Login cancelled or failed.")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" Login failed: {exc}")
|
||||||
|
return
|
||||||
|
elif choice == "2":
|
||||||
|
try:
|
||||||
|
new_key = input(" Token (COPILOT_GITHUB_TOKEN): ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
if not new_key:
|
||||||
|
print(" Cancelled.")
|
||||||
|
return
|
||||||
|
# Validate token type
|
||||||
|
try:
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token(new_key)
|
||||||
|
if not valid:
|
||||||
|
print(f" ✗ {msg}")
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
save_env_value("COPILOT_GITHUB_TOKEN", new_key)
|
||||||
|
print(" Token saved.")
|
||||||
|
print()
|
||||||
|
else:
|
||||||
|
print(" Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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=""):
|
def _model_flow_kimi(config, current_model=""):
|
||||||
"""Kimi / Moonshot model selection with automatic endpoint routing.
|
"""Kimi / Moonshot model selection with automatic endpoint routing.
|
||||||
|
|
||||||
|
|
@ -2642,7 +3050,7 @@ For more help on a command:
|
||||||
)
|
)
|
||||||
chat_parser.add_argument(
|
chat_parser.add_argument(
|
||||||
"--provider",
|
"--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,
|
default=None,
|
||||||
help="Inference provider (default: auto)"
|
help="Inference provider (default: auto)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ import urllib.error
|
||||||
from difflib import get_close_matches
|
from difflib import get_close_matches
|
||||||
from typing import Any, Optional
|
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)
|
# (model_id, display description shown in menus)
|
||||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||||
("anthropic/claude-opus-4.6", "recommended"),
|
("anthropic/claude-opus-4.6", "recommended"),
|
||||||
|
|
@ -55,6 +65,25 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
"gpt-5.1-codex-mini",
|
"gpt-5.1-codex-mini",
|
||||||
"gpt-5.1-codex-max",
|
"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": [
|
"zai": [
|
||||||
"glm-5",
|
"glm-5",
|
||||||
"glm-4.7",
|
"glm-4.7",
|
||||||
|
|
@ -173,7 +202,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
_PROVIDER_LABELS = {
|
_PROVIDER_LABELS = {
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"openai-codex": "OpenAI Codex",
|
"openai-codex": "OpenAI Codex",
|
||||||
|
"copilot-acp": "GitHub Copilot ACP",
|
||||||
"nous": "Nous Portal",
|
"nous": "Nous Portal",
|
||||||
|
"copilot": "GitHub Copilot",
|
||||||
"zai": "Z.AI / GLM",
|
"zai": "Z.AI / GLM",
|
||||||
"kimi-coding": "Kimi / Moonshot",
|
"kimi-coding": "Kimi / Moonshot",
|
||||||
"minimax": "MiniMax",
|
"minimax": "MiniMax",
|
||||||
|
|
@ -193,6 +224,12 @@ _PROVIDER_ALIASES = {
|
||||||
"z-ai": "zai",
|
"z-ai": "zai",
|
||||||
"z.ai": "zai",
|
"z.ai": "zai",
|
||||||
"zhipu": "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",
|
"kimi": "kimi-coding",
|
||||||
"moonshot": "kimi-coding",
|
"moonshot": "kimi-coding",
|
||||||
"minimax-china": "minimax-cn",
|
"minimax-china": "minimax-cn",
|
||||||
|
|
@ -246,7 +283,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
# Canonical providers in display order
|
# Canonical providers in display order
|
||||||
_PROVIDER_ORDER = [
|
_PROVIDER_ORDER = [
|
||||||
"openrouter", "nous", "openai-codex",
|
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||||
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||||
"opencode-zen", "opencode-go",
|
"opencode-zen", "opencode-go",
|
||||||
"ai-gateway", "deepseek", "custom",
|
"ai-gateway", "deepseek", "custom",
|
||||||
|
|
@ -467,6 +504,17 @@ def provider_label(provider: Optional[str]) -> str:
|
||||||
return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
|
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]:
|
def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||||
"""Return the best known model catalog for a provider.
|
"""Return the best known model catalog for a provider.
|
||||||
|
|
||||||
|
|
@ -480,6 +528,15 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||||
from hermes_cli.codex_models import get_codex_model_ids
|
from hermes_cli.codex_models import get_codex_model_ids
|
||||||
|
|
||||||
return 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":
|
if normalized == "nous":
|
||||||
# Try live Nous Portal /models endpoint
|
# Try live Nous Portal /models endpoint
|
||||||
try:
|
try:
|
||||||
|
|
@ -558,6 +615,306 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||||
return None
|
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]:
|
||||||
|
"""Standard headers for Copilot API requests.
|
||||||
|
|
||||||
|
Includes Openai-Intent and x-initiator headers that opencode and the
|
||||||
|
Copilot CLI send on every request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
return copilot_request_headers(is_agent_turn=True)
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"Editor-Version": COPILOT_EDITOR_VERSION,
|
||||||
|
"User-Agent": "HermesAgent/1.0",
|
||||||
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"x-initiator": "agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 _should_use_copilot_responses_api(model_id: str) -> bool:
|
||||||
|
"""Decide whether a Copilot model should use the Responses API.
|
||||||
|
|
||||||
|
Replicates opencode's ``shouldUseCopilotResponsesApi`` logic:
|
||||||
|
GPT-5+ models use Responses API, except ``gpt-5-mini`` which uses
|
||||||
|
Chat Completions. All non-GPT models (Claude, Gemini, etc.) use
|
||||||
|
Chat Completions.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.match(r"^gpt-(\d+)", model_id)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
major = int(match.group(1))
|
||||||
|
return major >= 5 and not model_id.startswith("gpt-5-mini")
|
||||||
|
|
||||||
|
|
||||||
|
def copilot_model_api_mode(
|
||||||
|
model_id: Optional[str],
|
||||||
|
*,
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Determine the API mode for a Copilot model.
|
||||||
|
|
||||||
|
Uses the model ID pattern (matching opencode's approach) as the
|
||||||
|
primary signal. Falls back to the catalog's ``supported_endpoints``
|
||||||
|
only for models not covered by the pattern check.
|
||||||
|
"""
|
||||||
|
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
|
||||||
|
if not normalized:
|
||||||
|
return "chat_completions"
|
||||||
|
|
||||||
|
# Primary: model ID pattern (matches opencode's shouldUseCopilotResponsesApi)
|
||||||
|
if _should_use_copilot_responses_api(normalized):
|
||||||
|
return "codex_responses"
|
||||||
|
|
||||||
|
# Secondary: check catalog for non-GPT-5 models (Claude via /v1/messages, etc.)
|
||||||
|
if catalog is None and api_key:
|
||||||
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
# For non-GPT-5 models, check if they only support messages API
|
||||||
|
if "/v1/messages" in supported_endpoints and "/chat/completions" not in supported_endpoints:
|
||||||
|
return "anthropic_messages"
|
||||||
|
|
||||||
|
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(
|
def probe_api_models(
|
||||||
api_key: Optional[str],
|
api_key: Optional[str],
|
||||||
base_url: Optional[str],
|
base_url: Optional[str],
|
||||||
|
|
@ -574,6 +931,16 @@ def probe_api_models(
|
||||||
"used_fallback": False,
|
"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"):
|
if normalized.endswith("/v1"):
|
||||||
alternate_base = normalized[:-3].rstrip("/")
|
alternate_base = normalized[:-3].rstrip("/")
|
||||||
else:
|
else:
|
||||||
|
|
@ -587,6 +954,8 @@ def probe_api_models(
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {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:
|
for candidate_base, is_fallback in candidates:
|
||||||
url = candidate_base.rstrip("/") + "/models"
|
url = candidate_base.rstrip("/") + "/models"
|
||||||
|
|
@ -677,6 +1046,12 @@ def validate_requested_model(
|
||||||
normalized = normalize_provider(provider)
|
normalized = normalize_provider(provider)
|
||||||
if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url:
|
if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url:
|
||||||
normalized = "custom"
|
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:
|
if not requested:
|
||||||
return {
|
return {
|
||||||
|
|
@ -698,7 +1073,7 @@ def validate_requested_model(
|
||||||
probe = probe_api_models(api_key, base_url)
|
probe = probe_api_models(api_key, base_url)
|
||||||
api_models = probe.get("models")
|
api_models = probe.get("models")
|
||||||
if api_models is not None:
|
if api_models is not None:
|
||||||
if requested in set(api_models):
|
if requested_for_lookup in set(api_models):
|
||||||
return {
|
return {
|
||||||
"accepted": True,
|
"accepted": True,
|
||||||
"persist": True,
|
"persist": True,
|
||||||
|
|
@ -747,7 +1122,7 @@ def validate_requested_model(
|
||||||
api_models = fetch_api_models(api_key, base_url)
|
api_models = fetch_api_models(api_key, base_url)
|
||||||
|
|
||||||
if api_models is not None:
|
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
|
# API confirmed the model exists
|
||||||
return {
|
return {
|
||||||
"accepted": True,
|
"accepted": True,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from hermes_cli.auth import (
|
||||||
resolve_nous_runtime_credentials,
|
resolve_nous_runtime_credentials,
|
||||||
resolve_codex_runtime_credentials,
|
resolve_codex_runtime_credentials,
|
||||||
resolve_api_key_provider_credentials,
|
resolve_api_key_provider_credentials,
|
||||||
|
resolve_external_process_provider_credentials,
|
||||||
)
|
)
|
||||||
from hermes_cli.config import load_config
|
from hermes_cli.config import load_config
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
|
|
@ -33,7 +34,24 @@ def _get_model_config() -> Dict[str, Any]:
|
||||||
return {}
|
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]:
|
def _parse_api_mode(raw: Any) -> Optional[str]:
|
||||||
|
|
@ -267,6 +285,19 @@ def resolve_runtime_provider(
|
||||||
"requested_provider": requested_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)
|
# Anthropic (native Messages API)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
from agent.anthropic_adapter import resolve_anthropic_token
|
from agent.anthropic_adapter import resolve_anthropic_token
|
||||||
|
|
@ -302,9 +333,13 @@ def resolve_runtime_provider(
|
||||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||||
if pconfig and pconfig.auth_type == "api_key":
|
if pconfig and pconfig.auth_type == "api_key":
|
||||||
creds = resolve_api_key_provider_credentials(provider)
|
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 {
|
return {
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"api_mode": "chat_completions",
|
"api_mode": api_mode,
|
||||||
"base_url": creds.get("base_url", "").rstrip("/"),
|
"base_url": creds.get("base_url", "").rstrip("/"),
|
||||||
"api_key": creds.get("api_key", ""),
|
"api_key": creds.get("api_key", ""),
|
||||||
"source": creds.get("source", "env"),
|
"source": creds.get("source", "env"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Default model lists per provider — used as fallback when the live
|
||||||
# /models endpoint can't be reached.
|
# /models endpoint can't be reached.
|
||||||
_DEFAULT_PROVIDER_MODELS = {
|
_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"],
|
"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"],
|
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||||
"minimax": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
"minimax": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "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):
|
def _setup_provider_model_selection(config, provider_id, current_model, prompt_choice, prompt_fn):
|
||||||
"""Model selection for API-key providers with live /models detection.
|
"""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.
|
hardcoded default list with a warning if the endpoint is unreachable.
|
||||||
Always offers a 'Custom model' escape hatch.
|
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.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]
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||||
|
is_copilot_catalog_provider = provider_id in {"copilot", "copilot-acp"}
|
||||||
|
|
||||||
# Resolve API key and base URL for the probe
|
# Resolve API key and base URL for the probe
|
||||||
api_key = ""
|
if is_copilot_catalog_provider:
|
||||||
for ev in pconfig.api_key_env_vars:
|
api_key = ""
|
||||||
api_key = get_env_value(ev) or os.getenv(ev, "")
|
if provider_id == "copilot":
|
||||||
if api_key:
|
creds = resolve_api_key_provider_credentials(provider_id)
|
||||||
break
|
api_key = creds.get("api_key", "")
|
||||||
base_url_env = pconfig.base_url_env_var or ""
|
base_url = creds.get("base_url", "") or pconfig.inference_base_url
|
||||||
base_url = (get_env_value(base_url_env) if base_url_env else "") 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
|
# 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:
|
if live_models:
|
||||||
provider_models = live_models
|
provider_models = live_models
|
||||||
print_info(f"Found {len(live_models)} model(s) from {pconfig.name} API")
|
print_info(f"Found {len(live_models)} model(s) from {pconfig.name} API")
|
||||||
else:
|
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:
|
if provider_models:
|
||||||
print_warning(
|
print_warning(
|
||||||
f"Could not auto-detect models from {pconfig.name} API — showing defaults.\n"
|
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
|
keep_idx = len(model_choices) - 1
|
||||||
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
|
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
|
||||||
|
|
||||||
|
selected_model = current_model
|
||||||
|
|
||||||
if model_idx < len(provider_models):
|
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):
|
elif model_idx == len(provider_models):
|
||||||
custom = prompt_fn("Enter model name")
|
custom = prompt_fn("Enter model name")
|
||||||
if custom:
|
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:
|
else:
|
||||||
# "Keep current" selected — validate it's compatible with the new
|
# "Keep current" selected — validate it's compatible with the new
|
||||||
# provider. OpenRouter-formatted names (containing "/") won't work
|
# 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"and won't work with {pconfig.name}. "
|
||||||
f"Switching to {provider_models[0]}."
|
f"Switching to {provider_models[0]}."
|
||||||
)
|
)
|
||||||
|
selected_model = provider_models[0]
|
||||||
_set_default_model(config, 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:
|
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
|
||||||
disk_model = load_config().get("model")
|
disk_model = load_config().get("model")
|
||||||
|
|
@ -673,6 +810,8 @@ def setup_model_provider(config: dict):
|
||||||
resolve_codex_runtime_credentials,
|
resolve_codex_runtime_credentials,
|
||||||
DEFAULT_CODEX_BASE_URL,
|
DEFAULT_CODEX_BASE_URL,
|
||||||
detect_external_credentials,
|
detect_external_credentials,
|
||||||
|
get_auth_status,
|
||||||
|
resolve_api_key_provider_credentials,
|
||||||
)
|
)
|
||||||
|
|
||||||
print_header("Inference Provider")
|
print_header("Inference Provider")
|
||||||
|
|
@ -682,6 +821,8 @@ def setup_model_provider(config: dict):
|
||||||
existing_or = get_env_value("OPENROUTER_API_KEY")
|
existing_or = get_env_value("OPENROUTER_API_KEY")
|
||||||
active_oauth = get_active_provider()
|
active_oauth = get_active_provider()
|
||||||
existing_custom = get_env_value("OPENAI_BASE_URL")
|
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 {}
|
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
|
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
|
# Detect if any provider is already configured
|
||||||
has_any_provider = bool(
|
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
|
# Build "keep current" label
|
||||||
|
|
@ -741,6 +887,8 @@ def setup_model_provider(config: dict):
|
||||||
"Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)",
|
"Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)",
|
||||||
"OpenCode Zen (35+ curated models, pay-as-you-go)",
|
"OpenCode Zen (35+ curated models, pay-as-you-go)",
|
||||||
"OpenCode Go (open models, $10/month subscription)",
|
"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:
|
if keep_label:
|
||||||
provider_choices.append(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)
|
_set_model_provider(config, "opencode-go", pconfig.inference_base_url)
|
||||||
selected_base_url = 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
|
# Normalize "keep current" to an explicit provider so downstream logic
|
||||||
# doesn't fall back to the generic OpenRouter/static-model path.
|
# doesn't fall back to the generic OpenRouter/static-model path.
|
||||||
if selected_provider is None:
|
if selected_provider is None:
|
||||||
|
|
@ -1444,6 +1641,8 @@ def setup_model_provider(config: dict):
|
||||||
if _vision_needs_setup:
|
if _vision_needs_setup:
|
||||||
_prov_names = {
|
_prov_names = {
|
||||||
"nous-api": "Nous Portal API key",
|
"nous-api": "Nous Portal API key",
|
||||||
|
"copilot": "GitHub Copilot",
|
||||||
|
"copilot-acp": "GitHub Copilot ACP",
|
||||||
"zai": "Z.AI / GLM",
|
"zai": "Z.AI / GLM",
|
||||||
"kimi-coding": "Kimi / Moonshot",
|
"kimi-coding": "Kimi / Moonshot",
|
||||||
"minimax": "MiniMax",
|
"minimax": "MiniMax",
|
||||||
|
|
@ -1583,7 +1782,15 @@ def setup_model_provider(config: dict):
|
||||||
_set_default_model(config, custom)
|
_set_default_model(config, custom)
|
||||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||||
_set_model_provider(config, "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(
|
_setup_provider_model_selection(
|
||||||
config, selected_provider, current_model,
|
config, selected_provider, current_model,
|
||||||
prompt_choice, prompt,
|
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.
|
# 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
|
# This prevents a race condition where the gateway picks up a new provider
|
||||||
# before the model name has been updated to match.
|
# 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)
|
_update_config_for_provider(selected_provider, selected_base_url)
|
||||||
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hermes-agent"
|
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"
|
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
|
||||||
121
run_agent.py
121
run_agent.py
|
|
@ -372,6 +372,10 @@ class AIAgent:
|
||||||
api_key: str = None,
|
api_key: str = None,
|
||||||
provider: str = None,
|
provider: str = None,
|
||||||
api_mode: 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
|
model: str = "anthropic/claude-opus-4.6", # OpenRouter format
|
||||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||||
tool_delay: float = 1.0,
|
tool_delay: float = 1.0,
|
||||||
|
|
@ -477,6 +481,8 @@ class AIAgent:
|
||||||
self.base_url = base_url or OPENROUTER_BASE_URL
|
self.base_url = base_url or OPENROUTER_BASE_URL
|
||||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||||
self.provider = provider_name or "openrouter"
|
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"}:
|
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
|
||||||
self.api_mode = api_mode
|
self.api_mode = api_mode
|
||||||
elif self.provider == "openai-codex":
|
elif self.provider == "openai-codex":
|
||||||
|
|
@ -671,6 +677,9 @@ class AIAgent:
|
||||||
# Explicit credentials from CLI/gateway — construct directly.
|
# Explicit credentials from CLI/gateway — construct directly.
|
||||||
# The runtime provider resolver already handled auth for us.
|
# The runtime provider resolver already handled auth for us.
|
||||||
client_kwargs = {"api_key": api_key, "base_url": base_url}
|
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
|
effective_base = base_url
|
||||||
if "openrouter" in effective_base.lower():
|
if "openrouter" in effective_base.lower():
|
||||||
client_kwargs["default_headers"] = {
|
client_kwargs["default_headers"] = {
|
||||||
|
|
@ -678,6 +687,10 @@ class AIAgent:
|
||||||
"X-OpenRouter-Title": "Hermes Agent",
|
"X-OpenRouter-Title": "Hermes Agent",
|
||||||
"X-OpenRouter-Categories": "productivity,cli-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():
|
elif "api.kimi.com" in effective_base.lower():
|
||||||
client_kwargs["default_headers"] = {
|
client_kwargs["default_headers"] = {
|
||||||
"User-Agent": "KimiCLI/1.3",
|
"User-Agent": "KimiCLI/1.3",
|
||||||
|
|
@ -2789,10 +2802,23 @@ class AIAgent:
|
||||||
|
|
||||||
if isinstance(client, Mock):
|
if isinstance(client, Mock):
|
||||||
return False
|
return False
|
||||||
|
if bool(getattr(client, "is_closed", False)):
|
||||||
|
return True
|
||||||
http_client = getattr(client, "_client", None)
|
http_client = getattr(client, "_client", None)
|
||||||
return bool(getattr(http_client, "is_closed", False))
|
return bool(getattr(http_client, "is_closed", False))
|
||||||
|
|
||||||
def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: bool) -> Any:
|
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)
|
client = OpenAI(**client_kwargs)
|
||||||
logger.info(
|
logger.info(
|
||||||
"OpenAI client created (%s, shared=%s) %s",
|
"OpenAI client created (%s, shared=%s) %s",
|
||||||
|
|
@ -3652,6 +3678,11 @@ class AIAgent:
|
||||||
if not instructions:
|
if not instructions:
|
||||||
instructions = DEFAULT_AGENT_IDENTITY
|
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)
|
# Resolve reasoning effort: config > default (medium)
|
||||||
reasoning_effort = "medium"
|
reasoning_effort = "medium"
|
||||||
reasoning_enabled = True
|
reasoning_enabled = True
|
||||||
|
|
@ -3669,13 +3700,23 @@ class AIAgent:
|
||||||
"tool_choice": "auto",
|
"tool_choice": "auto",
|
||||||
"parallel_tool_calls": True,
|
"parallel_tool_calls": True,
|
||||||
"store": False,
|
"store": False,
|
||||||
"prompt_cache_key": self.session_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not is_github_responses:
|
||||||
|
kwargs["prompt_cache_key"] = self.session_id
|
||||||
|
|
||||||
if reasoning_enabled:
|
if reasoning_enabled:
|
||||||
kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"}
|
if is_github_responses:
|
||||||
kwargs["include"] = ["reasoning.encrypted_content"]
|
# Copilot's Responses route advertises reasoning-effort support,
|
||||||
else:
|
# 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"] = []
|
kwargs["include"] = []
|
||||||
|
|
||||||
if self.max_tokens is not None:
|
if self.max_tokens is not None:
|
||||||
|
|
@ -3746,6 +3787,10 @@ class AIAgent:
|
||||||
extra_body = {}
|
extra_body = {}
|
||||||
|
|
||||||
_is_openrouter = "openrouter" in self._base_url_lower
|
_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-
|
# Provider preferences (only, ignore, order, sort) are OpenRouter-
|
||||||
# specific. Only send to OpenRouter-compatible endpoints.
|
# specific. Only send to OpenRouter-compatible endpoints.
|
||||||
|
|
@ -3756,19 +3801,24 @@ class AIAgent:
|
||||||
_is_nous = "nousresearch" in self._base_url_lower
|
_is_nous = "nousresearch" in self._base_url_lower
|
||||||
|
|
||||||
if self._supports_reasoning_extra_body():
|
if self._supports_reasoning_extra_body():
|
||||||
if self.reasoning_config is not None:
|
if _is_github_models:
|
||||||
rc = dict(self.reasoning_config)
|
github_reasoning = self._github_models_reasoning_extra_body()
|
||||||
# Nous Portal requires reasoning enabled — don't send
|
if github_reasoning is not None:
|
||||||
# enabled=false to it (would cause 400).
|
extra_body["reasoning"] = github_reasoning
|
||||||
if _is_nous and rc.get("enabled") is False:
|
|
||||||
pass # omit reasoning entirely for Nous when disabled
|
|
||||||
else:
|
|
||||||
extra_body["reasoning"] = rc
|
|
||||||
else:
|
else:
|
||||||
extra_body["reasoning"] = {
|
if self.reasoning_config is not None:
|
||||||
"enabled": True,
|
rc = dict(self.reasoning_config)
|
||||||
"effort": "medium"
|
# 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
|
# Nous Portal product attribution
|
||||||
if _is_nous:
|
if _is_nous:
|
||||||
|
|
@ -3790,6 +3840,13 @@ class AIAgent:
|
||||||
return True
|
return True
|
||||||
if "ai-gateway.vercel.sh" in self._base_url_lower:
|
if "ai-gateway.vercel.sh" in self._base_url_lower:
|
||||||
return True
|
return True
|
||||||
|
if "models.github.ai" in self._base_url_lower or "api.githubcopilot.com" in self._base_url_lower:
|
||||||
|
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 self._base_url_lower:
|
if "openrouter" not in self._base_url_lower:
|
||||||
return False
|
return False
|
||||||
if "api.mistral.ai" in self._base_url_lower:
|
if "api.mistral.ai" in self._base_url_lower:
|
||||||
|
|
@ -3806,6 +3863,38 @@ class AIAgent:
|
||||||
)
|
)
|
||||||
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
|
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:
|
def _build_assistant_message(self, assistant_message, finish_reason: str) -> dict:
|
||||||
"""Build a normalized assistant message dict from an API response message.
|
"""Build a normalized assistant message dict from an API response message.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,31 @@ class TestVisionClientFallback:
|
||||||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||||
assert model == "claude-haiku-4-5-20251001"
|
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):
|
def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch):
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
||||||
with (
|
with (
|
||||||
|
|
|
||||||
208
tests/hermes_cli/test_copilot_auth.py
Normal file
208
tests/hermes_cli/test_copilot_auth.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenValidation:
|
||||||
|
"""Token type validation."""
|
||||||
|
|
||||||
|
def test_classic_pat_rejected(self):
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
|
||||||
|
assert valid is False
|
||||||
|
assert "Classic Personal Access Tokens" in msg
|
||||||
|
assert "ghp_" in msg
|
||||||
|
|
||||||
|
def test_oauth_token_accepted(self):
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234")
|
||||||
|
assert valid is True
|
||||||
|
|
||||||
|
def test_fine_grained_pat_accepted(self):
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234")
|
||||||
|
assert valid is True
|
||||||
|
|
||||||
|
def test_github_app_token_accepted(self):
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234")
|
||||||
|
assert valid is True
|
||||||
|
|
||||||
|
def test_empty_token_rejected(self):
|
||||||
|
from hermes_cli.copilot_auth import validate_copilot_token
|
||||||
|
valid, msg = validate_copilot_token("")
|
||||||
|
assert valid is False
|
||||||
|
|
||||||
|
def test_is_classic_pat(self):
|
||||||
|
from hermes_cli.copilot_auth import is_classic_pat
|
||||||
|
assert is_classic_pat("ghp_abc123") is True
|
||||||
|
assert is_classic_pat("gho_abc123") is False
|
||||||
|
assert is_classic_pat("github_pat_abc") is False
|
||||||
|
assert is_classic_pat("") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveToken:
|
||||||
|
"""Token resolution with env var priority."""
|
||||||
|
|
||||||
|
def test_copilot_github_token_first_priority(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first")
|
||||||
|
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
|
||||||
|
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
assert token == "gho_copilot_first"
|
||||||
|
assert source == "COPILOT_GITHUB_TOKEN"
|
||||||
|
|
||||||
|
def test_gh_token_second_priority(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
|
||||||
|
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
assert token == "gho_gh_second"
|
||||||
|
assert source == "GH_TOKEN"
|
||||||
|
|
||||||
|
def test_github_token_third_priority(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
assert token == "gho_github_third"
|
||||||
|
assert source == "GITHUB_TOKEN"
|
||||||
|
|
||||||
|
def test_classic_pat_in_env_skipped(self, monkeypatch):
|
||||||
|
"""Classic PATs in env vars should be skipped, not returned."""
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope")
|
||||||
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth")
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
# Should skip the ghp_ token and find the gho_ one
|
||||||
|
assert token == "gho_valid_oauth"
|
||||||
|
assert source == "GITHUB_TOKEN"
|
||||||
|
|
||||||
|
def test_gh_cli_fallback(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
|
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"):
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
assert token == "gho_from_cli"
|
||||||
|
assert source == "gh auth token"
|
||||||
|
|
||||||
|
def test_gh_cli_classic_pat_raises(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
|
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"):
|
||||||
|
with pytest.raises(ValueError, match="classic PAT"):
|
||||||
|
resolve_copilot_token()
|
||||||
|
|
||||||
|
def test_no_token_returns_empty(self, monkeypatch):
|
||||||
|
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||||
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
|
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None):
|
||||||
|
token, source = resolve_copilot_token()
|
||||||
|
assert token == ""
|
||||||
|
assert source == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestHeaders:
|
||||||
|
"""Copilot API header generation."""
|
||||||
|
|
||||||
|
def test_default_headers_include_openai_intent(self):
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
headers = copilot_request_headers()
|
||||||
|
assert headers["Openai-Intent"] == "conversation-edits"
|
||||||
|
assert headers["User-Agent"] == "HermesAgent/1.0"
|
||||||
|
assert "Editor-Version" in headers
|
||||||
|
|
||||||
|
def test_agent_turn_sets_initiator(self):
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
headers = copilot_request_headers(is_agent_turn=True)
|
||||||
|
assert headers["x-initiator"] == "agent"
|
||||||
|
|
||||||
|
def test_user_turn_sets_initiator(self):
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
headers = copilot_request_headers(is_agent_turn=False)
|
||||||
|
assert headers["x-initiator"] == "user"
|
||||||
|
|
||||||
|
def test_vision_header(self):
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
headers = copilot_request_headers(is_vision=True)
|
||||||
|
assert headers["Copilot-Vision-Request"] == "true"
|
||||||
|
|
||||||
|
def test_no_vision_header_by_default(self):
|
||||||
|
from hermes_cli.copilot_auth import copilot_request_headers
|
||||||
|
headers = copilot_request_headers()
|
||||||
|
assert "Copilot-Vision-Request" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotDefaultHeaders:
|
||||||
|
"""The models.py copilot_default_headers uses copilot_auth."""
|
||||||
|
|
||||||
|
def test_includes_openai_intent(self):
|
||||||
|
from hermes_cli.models import copilot_default_headers
|
||||||
|
headers = copilot_default_headers()
|
||||||
|
assert "Openai-Intent" in headers
|
||||||
|
assert headers["Openai-Intent"] == "conversation-edits"
|
||||||
|
|
||||||
|
def test_includes_x_initiator(self):
|
||||||
|
from hermes_cli.models import copilot_default_headers
|
||||||
|
headers = copilot_default_headers()
|
||||||
|
assert "x-initiator" in headers
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiModeSelection:
|
||||||
|
"""API mode selection matching opencode's shouldUseCopilotResponsesApi."""
|
||||||
|
|
||||||
|
def test_gpt5_uses_responses(self):
|
||||||
|
from hermes_cli.models import _should_use_copilot_responses_api
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.4") is True
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.4-mini") is True
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.3-codex") is True
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.2-codex") is True
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.2") is True
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5.1-codex-max") is True
|
||||||
|
|
||||||
|
def test_gpt5_mini_excluded(self):
|
||||||
|
from hermes_cli.models import _should_use_copilot_responses_api
|
||||||
|
assert _should_use_copilot_responses_api("gpt-5-mini") is False
|
||||||
|
|
||||||
|
def test_gpt4_uses_chat(self):
|
||||||
|
from hermes_cli.models import _should_use_copilot_responses_api
|
||||||
|
assert _should_use_copilot_responses_api("gpt-4.1") is False
|
||||||
|
assert _should_use_copilot_responses_api("gpt-4o") is False
|
||||||
|
assert _should_use_copilot_responses_api("gpt-4o-mini") is False
|
||||||
|
|
||||||
|
def test_non_gpt_uses_chat(self):
|
||||||
|
from hermes_cli.models import _should_use_copilot_responses_api
|
||||||
|
assert _should_use_copilot_responses_api("claude-sonnet-4.6") is False
|
||||||
|
assert _should_use_copilot_responses_api("claude-opus-4.6") is False
|
||||||
|
assert _should_use_copilot_responses_api("gemini-2.5-pro") is False
|
||||||
|
assert _should_use_copilot_responses_api("grok-code-fast-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvVarOrder:
|
||||||
|
"""PROVIDER_REGISTRY has correct env var order."""
|
||||||
|
|
||||||
|
def test_copilot_env_vars_include_copilot_github_token(self):
|
||||||
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
|
copilot = PROVIDER_REGISTRY["copilot"]
|
||||||
|
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
|
||||||
|
# COPILOT_GITHUB_TOKEN should be first
|
||||||
|
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
|
||||||
|
|
||||||
|
def test_copilot_env_vars_order_matches_docs(self):
|
||||||
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
|
copilot = PROVIDER_REGISTRY["copilot"]
|
||||||
|
assert copilot.api_key_env_vars == (
|
||||||
|
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"
|
||||||
|
)
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from hermes_cli.models import (
|
from hermes_cli.models import (
|
||||||
|
copilot_model_api_mode,
|
||||||
|
fetch_github_model_catalog,
|
||||||
curated_models_for_provider,
|
curated_models_for_provider,
|
||||||
fetch_api_models,
|
fetch_api_models,
|
||||||
|
github_model_reasoning_efforts,
|
||||||
|
normalize_copilot_model_id,
|
||||||
normalize_provider,
|
normalize_provider,
|
||||||
parse_model_input,
|
parse_model_input,
|
||||||
probe_api_models,
|
probe_api_models,
|
||||||
|
|
@ -116,6 +120,7 @@ class TestNormalizeProvider:
|
||||||
assert normalize_provider("glm") == "zai"
|
assert normalize_provider("glm") == "zai"
|
||||||
assert normalize_provider("kimi") == "kimi-coding"
|
assert normalize_provider("kimi") == "kimi-coding"
|
||||||
assert normalize_provider("moonshot") == "kimi-coding"
|
assert normalize_provider("moonshot") == "kimi-coding"
|
||||||
|
assert normalize_provider("github-copilot") == "copilot"
|
||||||
|
|
||||||
def test_case_insensitive(self):
|
def test_case_insensitive(self):
|
||||||
assert normalize_provider("OpenRouter") == "openrouter"
|
assert normalize_provider("OpenRouter") == "openrouter"
|
||||||
|
|
@ -125,6 +130,8 @@ class TestProviderLabel:
|
||||||
def test_known_labels_and_auto(self):
|
def test_known_labels_and_auto(self):
|
||||||
assert provider_label("anthropic") == "Anthropic"
|
assert provider_label("anthropic") == "Anthropic"
|
||||||
assert provider_label("kimi") == "Kimi / Moonshot"
|
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"
|
assert provider_label("auto") == "Auto"
|
||||||
|
|
||||||
def test_unknown_provider_preserves_original_name(self):
|
def test_unknown_provider_preserves_original_name(self):
|
||||||
|
|
@ -145,6 +152,24 @@ class TestProviderModelIds:
|
||||||
def test_zai_returns_glm_models(self):
|
def test_zai_returns_glm_models(self):
|
||||||
assert "glm-5" in provider_model_ids("zai")
|
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 --------------------------------------------------------
|
# -- fetch_api_models --------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -183,6 +208,112 @@ class TestFetchApiModels:
|
||||||
assert probe["resolved_base_url"] == "http://localhost:8000/v1"
|
assert probe["resolved_base_url"] == "http://localhost:8000/v1"
|
||||||
assert probe["used_fallback"] is True
|
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_gpt5_uses_responses(self):
|
||||||
|
"""GPT-5+ models should use Responses API (matching opencode)."""
|
||||||
|
assert copilot_model_api_mode("gpt-5.4") == "codex_responses"
|
||||||
|
assert copilot_model_api_mode("gpt-5.4-mini") == "codex_responses"
|
||||||
|
assert copilot_model_api_mode("gpt-5.3-codex") == "codex_responses"
|
||||||
|
assert copilot_model_api_mode("gpt-5.2-codex") == "codex_responses"
|
||||||
|
assert copilot_model_api_mode("gpt-5.2") == "codex_responses"
|
||||||
|
|
||||||
|
def test_copilot_api_mode_gpt5_mini_uses_chat(self):
|
||||||
|
"""gpt-5-mini is the exception — uses Chat Completions."""
|
||||||
|
assert copilot_model_api_mode("gpt-5-mini") == "chat_completions"
|
||||||
|
|
||||||
|
def test_copilot_api_mode_non_gpt5_uses_chat(self):
|
||||||
|
"""Non-GPT-5 models use Chat Completions."""
|
||||||
|
assert copilot_model_api_mode("gpt-4.1") == "chat_completions"
|
||||||
|
assert copilot_model_api_mode("gpt-4o") == "chat_completions"
|
||||||
|
assert copilot_model_api_mode("gpt-4o-mini") == "chat_completions"
|
||||||
|
assert copilot_model_api_mode("claude-sonnet-4.6") == "chat_completions"
|
||||||
|
assert copilot_model_api_mode("claude-opus-4.6") == "chat_completions"
|
||||||
|
assert copilot_model_api_mode("gemini-2.5-pro") == "chat_completions"
|
||||||
|
|
||||||
|
def test_copilot_api_mode_with_catalog_both_endpoints(self):
|
||||||
|
"""When catalog shows both endpoints, model ID pattern wins."""
|
||||||
|
catalog = [{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"supported_endpoints": ["/chat/completions", "/responses"],
|
||||||
|
}]
|
||||||
|
# GPT-5.4 should use responses even though chat/completions is listed
|
||||||
|
assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"
|
||||||
|
|
||||||
|
def test_copilot_api_mode_with_catalog_only_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 -----------------------------------------------
|
# -- validate — format checks -----------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ def _clear_provider_env(monkeypatch):
|
||||||
"OPENAI_BASE_URL",
|
"OPENAI_BASE_URL",
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
|
"GITHUB_TOKEN",
|
||||||
|
"GH_TOKEN",
|
||||||
"GLM_API_KEY",
|
"GLM_API_KEY",
|
||||||
"KIMI_API_KEY",
|
"KIMI_API_KEY",
|
||||||
"MINIMAX_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"
|
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):
|
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."""
|
"""Switching from custom to Codex should clear custom endpoint overrides."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,12 @@ from hermes_cli.auth import (
|
||||||
resolve_provider,
|
resolve_provider,
|
||||||
get_api_key_provider_status,
|
get_api_key_provider_status,
|
||||||
resolve_api_key_provider_credentials,
|
resolve_api_key_provider_credentials,
|
||||||
|
get_external_process_provider_status,
|
||||||
|
resolve_external_process_provider_credentials,
|
||||||
get_auth_status,
|
get_auth_status,
|
||||||
AuthError,
|
AuthError,
|
||||||
KIMI_CODE_BASE_URL,
|
KIMI_CODE_BASE_URL,
|
||||||
|
_try_gh_cli_token,
|
||||||
_resolve_kimi_base_url,
|
_resolve_kimi_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,6 +36,8 @@ class TestProviderRegistry:
|
||||||
"""Test that new providers are correctly registered."""
|
"""Test that new providers are correctly registered."""
|
||||||
|
|
||||||
@pytest.mark.parametrize("provider_id,name,auth_type", [
|
@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"),
|
("zai", "Z.AI / GLM", "api_key"),
|
||||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||||
("minimax", "MiniMax", "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.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY")
|
||||||
assert pconfig.base_url_env_var == "GLM_BASE_URL"
|
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 == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||||
|
assert pconfig.base_url_env_var == ""
|
||||||
|
|
||||||
def test_kimi_env_vars(self):
|
def test_kimi_env_vars(self):
|
||||||
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
||||||
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
|
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
|
||||||
|
|
@ -78,6 +88,8 @@ class TestProviderRegistry:
|
||||||
assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
|
assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
|
||||||
|
|
||||||
def test_base_urls(self):
|
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["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["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
||||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/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",
|
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||||
"NOUS_API_KEY",
|
"NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
|
||||||
"OPENAI_BASE_URL",
|
"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("Z-AI") == "zai"
|
||||||
assert resolve_provider("Kimi") == "kimi-coding"
|
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):
|
def test_unknown_provider_raises(self):
|
||||||
with pytest.raises(AuthError):
|
with pytest.raises(AuthError):
|
||||||
resolve_provider("nonexistent-provider-xyz")
|
resolve_provider("nonexistent-provider-xyz")
|
||||||
|
|
@ -218,6 +241,10 @@ class TestResolveProvider:
|
||||||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||||
assert resolve_provider("auto") == "openrouter"
|
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
|
# API Key Provider Status tests
|
||||||
|
|
@ -251,12 +278,41 @@ class TestApiKeyProviderStatus:
|
||||||
status = get_api_key_provider_status("kimi-coding")
|
status = get_api_key_provider_status("kimi-coding")
|
||||||
assert status["base_url"] == "https://custom.kimi.example/v1"
|
assert status["base_url"] == "https://custom.kimi.example/v1"
|
||||||
|
|
||||||
|
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_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):
|
def test_get_auth_status_dispatches_to_api_key(self, monkeypatch):
|
||||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
||||||
status = get_auth_status("minimax")
|
status = get_auth_status("minimax")
|
||||||
assert status["configured"] is True
|
assert status["configured"] is True
|
||||||
assert status["provider"] == "minimax"
|
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):
|
def test_non_api_key_provider(self):
|
||||||
status = get_api_key_provider_status("nous")
|
status = get_api_key_provider_status("nous")
|
||||||
assert status["configured"] is False
|
assert status["configured"] is False
|
||||||
|
|
@ -276,6 +332,61 @@ class TestResolveApiKeyProviderCredentials:
|
||||||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||||
assert creds["source"] == "GLM_API_KEY"
|
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.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
||||||
|
creds = resolve_api_key_provider_credentials("copilot")
|
||||||
|
assert creds["provider"] == "copilot"
|
||||||
|
assert creds["api_key"] == "gho_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):
|
def test_resolve_kimi_with_key(self, monkeypatch):
|
||||||
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
|
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
|
||||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||||
|
|
@ -403,6 +514,53 @@ class TestRuntimeProviderResolution:
|
||||||
assert result["provider"] == "kimi-coding"
|
assert result["provider"] == "kimi-coding"
|
||||||
assert result["api_key"] == "auto-kimi-key"
|
assert result["api_key"] == "auto-kimi-key"
|
||||||
|
|
||||||
|
def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_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"] == "gho_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.copilot_auth._try_gh_cli_token", lambda: "gho_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
|
# _has_any_provider_configured tests
|
||||||
|
|
@ -430,6 +588,16 @@ class TestHasAnyProviderConfigured:
|
||||||
from hermes_cli.main import _has_any_provider_configured
|
from hermes_cli.main import _has_any_provider_configured
|
||||||
assert _has_any_provider_configured() is True
|
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.copilot_auth._try_gh_cli_token", lambda: "gho_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
|
# Kimi Code auto-detection tests
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ def config_home(tmp_path, monkeypatch):
|
||||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", 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_BASE_URL", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("OPENROUTER_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')}"
|
f"provider should be 'kimi-coding', got {model.get('provider')}"
|
||||||
)
|
)
|
||||||
assert model.get("default") == "kimi-k2.5"
|
assert model.get("default") == "kimi-k2.5"
|
||||||
|
|
||||||
|
def test_copilot_provider_saved_when_selected(self, config_home):
|
||||||
|
"""_model_flow_copilot should persist provider/base_url/model together."""
|
||||||
|
from hermes_cli.main import _model_flow_copilot
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||||
|
return_value={
|
||||||
|
"provider": "copilot",
|
||||||
|
"api_key": "gh-cli-token",
|
||||||
|
"base_url": "https://api.githubcopilot.com",
|
||||||
|
"source": "gh auth token",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.models.fetch_github_model_catalog",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "gpt-4.1",
|
||||||
|
"capabilities": {"type": "chat", "supports": {}},
|
||||||
|
"supported_endpoints": ["/chat/completions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth._prompt_model_selection",
|
||||||
|
return_value="gpt-5.4",
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.main._prompt_reasoning_effort_selection",
|
||||||
|
return_value="high",
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth.deactivate_provider",
|
||||||
|
):
|
||||||
|
_model_flow_copilot(load_config(), "old-model")
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||||
|
model = config.get("model")
|
||||||
|
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||||
|
assert model.get("provider") == "copilot"
|
||||||
|
assert model.get("base_url") == "https://api.githubcopilot.com"
|
||||||
|
assert model.get("default") == "gpt-5.4"
|
||||||
|
assert model.get("api_mode") == "codex_responses"
|
||||||
|
assert config["agent"]["reasoning_effort"] == "high"
|
||||||
|
|
||||||
|
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||||
|
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||||
|
from hermes_cli.main import _model_flow_copilot_acp
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"hermes_cli.auth.get_external_process_provider_status",
|
||||||
|
return_value={
|
||||||
|
"resolved_command": "/usr/local/bin/copilot",
|
||||||
|
"command": "copilot",
|
||||||
|
"base_url": "acp://copilot",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth.resolve_external_process_provider_credentials",
|
||||||
|
return_value={
|
||||||
|
"provider": "copilot-acp",
|
||||||
|
"api_key": "copilot-acp",
|
||||||
|
"base_url": "acp://copilot",
|
||||||
|
"command": "/usr/local/bin/copilot",
|
||||||
|
"args": ["--acp", "--stdio"],
|
||||||
|
"source": "process",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||||
|
return_value={
|
||||||
|
"provider": "copilot",
|
||||||
|
"api_key": "gh-cli-token",
|
||||||
|
"base_url": "https://api.githubcopilot.com",
|
||||||
|
"source": "gh auth token",
|
||||||
|
},
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.models.fetch_github_model_catalog",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "gpt-4.1",
|
||||||
|
"capabilities": {"type": "chat", "supports": {}},
|
||||||
|
"supported_endpoints": ["/chat/completions"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth._prompt_model_selection",
|
||||||
|
return_value="gpt-5.4",
|
||||||
|
), patch(
|
||||||
|
"hermes_cli.auth.deactivate_provider",
|
||||||
|
):
|
||||||
|
_model_flow_copilot_acp(load_config(), "old-model")
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||||
|
model = config.get("model")
|
||||||
|
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||||
|
assert model.get("provider") == "copilot-acp"
|
||||||
|
assert model.get("base_url") == "acp://copilot"
|
||||||
|
assert model.get("default") == "gpt-5.4"
|
||||||
|
assert model.get("api_mode") == "chat_completions"
|
||||||
|
|
|
||||||
|
|
@ -631,6 +631,28 @@ class TestBuildApiKwargs:
|
||||||
kwargs = agent._build_api_kwargs(messages)
|
kwargs = agent._build_api_kwargs(messages)
|
||||||
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
|
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):
|
def test_max_tokens_injected(self, agent):
|
||||||
agent.max_tokens = 4096
|
agent.max_tokens = 4096
|
||||||
messages = [{"role": "user", "content": "hi"}]
|
messages = [{"role": "user", "content": "hi"}]
|
||||||
|
|
@ -2293,6 +2315,41 @@ class TestFallbackAnthropicProvider:
|
||||||
assert agent.client is mock_client
|
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:
|
class TestAnthropicBaseUrlPassthrough:
|
||||||
"""Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies."""
|
"""Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,27 @@ def _build_agent(monkeypatch):
|
||||||
return agent
|
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):
|
def _codex_message_response(text: str):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
output=[
|
output=[
|
||||||
|
|
@ -244,6 +265,28 @@ def test_build_api_kwargs_codex(monkeypatch):
|
||||||
assert "extra_body" not in kwargs
|
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):
|
def test_run_codex_stream_retries_when_completed_event_missing(monkeypatch):
|
||||||
agent = _build_agent(monkeypatch)
|
agent = _build_agent(monkeypatch)
|
||||||
calls = {"stream": 0}
|
calls = {"stream": 0}
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,8 @@ def _build_child_agent(
|
||||||
effective_base_url = override_base_url or parent_agent.base_url
|
effective_base_url = override_base_url or parent_agent.base_url
|
||||||
effective_api_key = override_api_key or parent_api_key
|
effective_api_key = override_api_key or parent_api_key
|
||||||
effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None)
|
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(
|
child = AIAgent(
|
||||||
base_url=effective_base_url,
|
base_url=effective_base_url,
|
||||||
|
|
@ -208,6 +210,8 @@ def _build_child_agent(
|
||||||
model=effective_model,
|
model=effective_model,
|
||||||
provider=effective_provider,
|
provider=effective_provider,
|
||||||
api_mode=effective_api_mode,
|
api_mode=effective_api_mode,
|
||||||
|
acp_command=effective_acp_command,
|
||||||
|
acp_args=effective_acp_args,
|
||||||
max_iterations=max_iterations,
|
max_iterations=max_iterations,
|
||||||
max_tokens=getattr(parent_agent, "max_tokens", None),
|
max_tokens=getattr(parent_agent, "max_tokens", None),
|
||||||
reasoning_config=getattr(parent_agent, "reasoning_config", None),
|
reasoning_config=getattr(parent_agent, "reasoning_config", None),
|
||||||
|
|
@ -228,6 +232,7 @@ def _build_child_agent(
|
||||||
tool_progress_callback=child_progress_cb,
|
tool_progress_callback=child_progress_cb,
|
||||||
iteration_budget=shared_budget,
|
iteration_budget=shared_budget,
|
||||||
)
|
)
|
||||||
|
child._delegate_saved_tool_names = list(_saved_tool_names)
|
||||||
|
|
||||||
# Set delegation depth so children can't spawn grandchildren
|
# Set delegation depth so children can't spawn grandchildren
|
||||||
child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1
|
child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1
|
||||||
|
|
@ -375,7 +380,11 @@ def _run_single_child(
|
||||||
finally:
|
finally:
|
||||||
# Restore the parent's tool names so the process-global is correct
|
# Restore the parent's tool names so the process-global is correct
|
||||||
# for any subsequent execute_code calls or other consumers.
|
# 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
|
# Unregister child from interrupt propagation
|
||||||
if hasattr(parent_agent, '_active_children'):
|
if hasattr(parent_agent, '_active_children'):
|
||||||
|
|
@ -626,6 +635,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
||||||
"base_url": runtime.get("base_url"),
|
"base_url": runtime.get("base_url"),
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"api_mode": runtime.get("api_mode"),
|
"api_mode": runtime.get("api_mode"),
|
||||||
|
"command": runtime.get("command"),
|
||||||
|
"args": list(runtime.get("args") or []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ Common options:
|
||||||
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
||||||
| `-m`, `--model <model>` | Override the model for this run. |
|
| `-m`, `--model <model>` | Override the model for this run. |
|
||||||
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
||||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. |
|
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. |
|
||||||
| `-v`, `--verbose` | Verbose output. |
|
| `-v`, `--verbose` | Verbose output. |
|
||||||
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
|
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
|
||||||
| `--resume <session>` / `--continue [name]` | Resume a session directly from `chat`. |
|
| `--resume <session>` / `--continue [name]` | Resume a session directly from `chat`. |
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||||
| `AI_GATEWAY_BASE_URL` | Override AI Gateway base URL (default: `https://ai-gateway.vercel.sh/v1`) |
|
| `AI_GATEWAY_BASE_URL` | Override AI Gateway base URL (default: `https://ai-gateway.vercel.sh/v1`) |
|
||||||
| `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) |
|
| `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) |
|
||||||
| `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) |
|
| `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) |
|
||||||
|
| `COPILOT_GITHUB_TOKEN` | GitHub token for Copilot API — first priority (OAuth `gho_*` or fine-grained PAT `github_pat_*`; classic PATs `ghp_*` are **not supported**) |
|
||||||
|
| `GH_TOKEN` | GitHub token — second priority for Copilot (also used by `gh` CLI) |
|
||||||
|
| `GITHUB_TOKEN` | GitHub token — third priority for Copilot |
|
||||||
|
| `HERMES_COPILOT_ACP_COMMAND` | Override Copilot ACP CLI binary path (default: `copilot`) |
|
||||||
|
| `COPILOT_CLI_PATH` | Alias for `HERMES_COPILOT_ACP_COMMAND` |
|
||||||
|
| `HERMES_COPILOT_ACP_ARGS` | Override Copilot ACP arguments (default: `--acp --stdio`) |
|
||||||
|
| `COPILOT_ACP_BASE_URL` | Override Copilot ACP base URL |
|
||||||
| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) |
|
| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) |
|
||||||
| `ZAI_API_KEY` | Alias for `GLM_API_KEY` |
|
| `ZAI_API_KEY` | Alias for `GLM_API_KEY` |
|
||||||
| `Z_AI_API_KEY` | Alias for `GLM_API_KEY` |
|
| `Z_AI_API_KEY` | Alias for `GLM_API_KEY` |
|
||||||
|
|
@ -48,7 +55,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||||
|
|
||||||
| Variable | Description |
|
| 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) |
|
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
|
||||||
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
|
| `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) |
|
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| **Nous Portal** | `hermes model` (OAuth, subscription-based) |
|
||||||
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
||||||
|
| **GitHub Copilot** | `hermes model` (OAuth device code flow, `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) |
|
||||||
|
| **GitHub Copilot ACP** | `hermes model` (spawns local `copilot --acp --stdio`) |
|
||||||
| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-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` |
|
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
||||||
| **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) |
|
| **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) |
|
||||||
|
|
@ -117,6 +119,59 @@ model:
|
||||||
`--provider claude` and `--provider claude-code` also work as shorthand for `--provider anthropic`.
|
`--provider claude` and `--provider claude-code` also work as shorthand for `--provider anthropic`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### GitHub Copilot
|
||||||
|
|
||||||
|
Hermes supports GitHub Copilot as a first-class provider with two modes:
|
||||||
|
|
||||||
|
**`copilot` — Direct Copilot API** (recommended). Uses your GitHub Copilot subscription to access GPT-5.x, Claude, Gemini, and other models through the Copilot API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes chat --provider copilot --model gpt-5.4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication options** (checked in this order):
|
||||||
|
|
||||||
|
1. `COPILOT_GITHUB_TOKEN` environment variable
|
||||||
|
2. `GH_TOKEN` environment variable
|
||||||
|
3. `GITHUB_TOKEN` environment variable
|
||||||
|
4. `gh auth token` CLI fallback
|
||||||
|
|
||||||
|
If no token is found, `hermes model` offers an **OAuth device code login** — the same flow used by the Copilot CLI and opencode.
|
||||||
|
|
||||||
|
:::warning Token types
|
||||||
|
The Copilot API does **not** support classic Personal Access Tokens (`ghp_*`). Supported token types:
|
||||||
|
|
||||||
|
| Type | Prefix | How to get |
|
||||||
|
|------|--------|------------|
|
||||||
|
| OAuth token | `gho_` | `hermes model` → GitHub Copilot → Login with GitHub |
|
||||||
|
| Fine-grained PAT | `github_pat_` | GitHub Settings → Developer settings → Fine-grained tokens (needs **Copilot Requests** permission) |
|
||||||
|
| GitHub App token | `ghu_` | Via GitHub App installation |
|
||||||
|
|
||||||
|
If your `gh auth token` returns a `ghp_*` token, use `hermes model` to authenticate via OAuth instead.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**API routing**: GPT-5+ models (except `gpt-5-mini`) automatically use the Responses API. All other models (GPT-4o, Claude, Gemini, etc.) use Chat Completions. Models are auto-detected from the live Copilot catalog.
|
||||||
|
|
||||||
|
**`copilot-acp` — Copilot ACP agent backend**. Spawns the local Copilot CLI as a subprocess:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes chat --provider copilot-acp --model copilot-acp
|
||||||
|
# Requires the GitHub Copilot CLI in PATH and an existing `copilot login` session
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permanent config:**
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
provider: "copilot"
|
||||||
|
default: "gpt-5.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Environment variable | Description |
|
||||||
|
|---------------------|-------------|
|
||||||
|
| `COPILOT_GITHUB_TOKEN` | GitHub token for Copilot API (first priority) |
|
||||||
|
| `HERMES_COPILOT_ACP_COMMAND` | Override the Copilot CLI binary path (default: `copilot`) |
|
||||||
|
| `HERMES_COPILOT_ACP_ARGS` | Override ACP args (default: `--acp --stdio`) |
|
||||||
|
|
||||||
### First-Class Chinese AI Providers
|
### First-Class Chinese AI Providers
|
||||||
|
|
||||||
These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select:
|
These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select:
|
||||||
|
|
@ -443,7 +498,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.
|
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
|
:::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).
|
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 +821,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.
|
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
|
### Full auxiliary config reference
|
||||||
|
|
||||||
|
|
@ -1244,7 +1299,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.
|
**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).
|
**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).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue