Merge PR #1879: feat: integrate GitHub Copilot providers
This commit is contained in:
commit
8422196e89
33 changed files with 2700 additions and 168 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 {}))
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,11 @@ Write only the summary body. Do not include any preamble or prefix; the system w
|
||||||
n_messages = len(messages)
|
n_messages = len(messages)
|
||||||
if n_messages <= self.protect_first_n + self.protect_last_n + 1:
|
if n_messages <= self.protect_first_n + self.protect_last_n + 1:
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
print(f"⚠️ Cannot compress: only {n_messages} messages (need > {self.protect_first_n + self.protect_last_n + 1})")
|
logger.warning(
|
||||||
|
"Cannot compress: only %d messages (need > %d)",
|
||||||
|
n_messages,
|
||||||
|
self.protect_first_n + self.protect_last_n + 1,
|
||||||
|
)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
compress_start = self.protect_first_n
|
compress_start = self.protect_first_n
|
||||||
|
|
@ -293,11 +297,23 @@ Write only the summary body. Do not include any preamble or prefix; the system w
|
||||||
display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
|
display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
|
||||||
|
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
print(f"\n📦 Context compression triggered ({display_tokens:,} tokens ≥ {self.threshold_tokens:,} threshold)")
|
logger.info(
|
||||||
print(f" 📊 Model context limit: {self.context_length:,} tokens ({self.threshold_percent*100:.0f}% = {self.threshold_tokens:,})")
|
"Context compression triggered (%d tokens >= %d threshold)",
|
||||||
|
display_tokens,
|
||||||
if not self.quiet_mode:
|
self.threshold_tokens,
|
||||||
print(f" 🗜️ Summarizing turns {compress_start+1}-{compress_end} ({len(turns_to_summarize)} turns)")
|
)
|
||||||
|
logger.info(
|
||||||
|
"Model context limit: %d tokens (%.0f%% = %d)",
|
||||||
|
self.context_length,
|
||||||
|
self.threshold_percent * 100,
|
||||||
|
self.threshold_tokens,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Summarizing turns %d-%d (%d turns)",
|
||||||
|
compress_start + 1,
|
||||||
|
compress_end,
|
||||||
|
len(turns_to_summarize),
|
||||||
|
)
|
||||||
|
|
||||||
summary = self._generate_summary(turns_to_summarize)
|
summary = self._generate_summary(turns_to_summarize)
|
||||||
|
|
||||||
|
|
@ -337,7 +353,7 @@ Write only the summary body. Do not include any preamble or prefix; the system w
|
||||||
compressed.append({"role": summary_role, "content": summary})
|
compressed.append({"role": summary_role, "content": summary})
|
||||||
else:
|
else:
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
print(" ⚠️ No summary model available — middle turns dropped without summary")
|
logger.warning("No summary model available — middle turns dropped without summary")
|
||||||
|
|
||||||
for i in range(compress_end, n_messages):
|
for i in range(compress_end, n_messages):
|
||||||
msg = messages[i].copy()
|
msg = messages[i].copy()
|
||||||
|
|
@ -354,7 +370,12 @@ Write only the summary body. Do not include any preamble or prefix; the system w
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||||
saved_estimate = display_tokens - new_estimate
|
saved_estimate = display_tokens - new_estimate
|
||||||
print(f" ✅ Compressed: {n_messages} → {len(compressed)} messages (~{saved_estimate:,} tokens saved)")
|
logger.info(
|
||||||
print(f" 💡 Compression #{self.compression_count} complete")
|
"Compressed: %d -> %d messages (~%d tokens saved)",
|
||||||
|
n_messages,
|
||||||
|
len(compressed),
|
||||||
|
saved_estimate,
|
||||||
|
)
|
||||||
|
logger.info("Compression #%d complete", self.compression_count)
|
||||||
|
|
||||||
return compressed
|
return compressed
|
||||||
|
|
|
||||||
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")
|
||||||
|
|
@ -1380,27 +1382,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]
|
||||||
|
|
@ -1676,6 +1686,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
|
||||||
|
|
@ -1687,9 +1699,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
|
||||||
|
|
@ -1719,6 +1735,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 []),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1787,6 +1805,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(
|
||||||
|
|
@ -1795,6 +1815,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,
|
||||||
|
|
@ -1831,6 +1853,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:
|
||||||
|
|
@ -3756,6 +3780,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,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from cron.jobs import get_due_jobs, mark_job_run, save_job_output
|
from cron.jobs import get_due_jobs, mark_job_run, save_job_output
|
||||||
|
|
||||||
|
# Sentinel: when a cron agent has nothing new to report, it can start its
|
||||||
|
# response with this marker to suppress delivery. Output is still saved
|
||||||
|
# locally for audit.
|
||||||
|
SILENT_MARKER = "[SILENT]"
|
||||||
|
|
||||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
|
||||||
|
|
@ -180,6 +185,17 @@ def _build_job_prompt(job: dict) -> str:
|
||||||
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
|
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
|
||||||
prompt = job.get("prompt", "")
|
prompt = job.get("prompt", "")
|
||||||
skills = job.get("skills")
|
skills = job.get("skills")
|
||||||
|
|
||||||
|
# Always prepend [SILENT] guidance so the cron agent can suppress
|
||||||
|
# delivery when it has nothing new or noteworthy to report.
|
||||||
|
silent_hint = (
|
||||||
|
"[SYSTEM: If you have nothing new or noteworthy to report, respond "
|
||||||
|
"with exactly \"[SILENT]\" (optionally followed by a brief internal "
|
||||||
|
"note). This suppresses delivery to the user while still saving "
|
||||||
|
"output locally. Only use [SILENT] when there are genuinely no "
|
||||||
|
"changes worth reporting.]\n\n"
|
||||||
|
)
|
||||||
|
prompt = silent_hint + prompt
|
||||||
if skills is None:
|
if skills is None:
|
||||||
legacy = job.get("skill")
|
legacy = job.get("skill")
|
||||||
skills = [legacy] if legacy else []
|
skills = [legacy] if legacy else []
|
||||||
|
|
@ -343,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 []),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -352,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,
|
||||||
|
|
@ -480,9 +500,16 @@ def tick(verbose: bool = True) -> int:
|
||||||
if verbose:
|
if verbose:
|
||||||
logger.info("Output saved to: %s", output_file)
|
logger.info("Output saved to: %s", output_file)
|
||||||
|
|
||||||
# Deliver the final response to the origin/target chat
|
# Deliver the final response to the origin/target chat.
|
||||||
|
# If the agent responded with [SILENT], skip delivery (but
|
||||||
|
# output is already saved above). Failed jobs always deliver.
|
||||||
deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}"
|
deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}"
|
||||||
if deliver_content:
|
should_deliver = bool(deliver_content)
|
||||||
|
if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER):
|
||||||
|
logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER)
|
||||||
|
should_deliver = False
|
||||||
|
|
||||||
|
if should_deliver:
|
||||||
try:
|
try:
|
||||||
_deliver_result(job, deliver_content)
|
_deliver_result(job, deliver_content)
|
||||||
except Exception as de:
|
except Exception as de:
|
||||||
|
|
|
||||||
|
|
@ -1364,16 +1364,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||||
self,
|
self,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
command_text: str,
|
command_text: str,
|
||||||
followup_msg: str = "Done~",
|
followup_msg: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Common handler for simple slash commands that dispatch a command string."""
|
"""Common handler for simple slash commands that dispatch a command string."""
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
event = self._build_slash_event(interaction, command_text)
|
event = self._build_slash_event(interaction, command_text)
|
||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
try:
|
if followup_msg:
|
||||||
await interaction.followup.send(followup_msg, ephemeral=True)
|
try:
|
||||||
except Exception as e:
|
await interaction.followup.send(followup_msg, ephemeral=True)
|
||||||
logger.debug("Discord followup failed: %s", e)
|
except Exception as e:
|
||||||
|
logger.debug("Discord followup failed: %s", e)
|
||||||
|
|
||||||
def _register_slash_commands(self) -> None:
|
def _register_slash_commands(self) -> None:
|
||||||
"""Register Discord slash commands on the command tree."""
|
"""Register Discord slash commands on the command tree."""
|
||||||
|
|
@ -1382,19 +1383,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||||
|
|
||||||
tree = self._client.tree
|
tree = self._client.tree
|
||||||
|
|
||||||
@tree.command(name="ask", description="Ask Hermes a question")
|
|
||||||
@discord.app_commands.describe(question="Your question for Hermes")
|
|
||||||
async def slash_ask(interaction: discord.Interaction, question: str):
|
|
||||||
await interaction.response.defer()
|
|
||||||
event = self._build_slash_event(interaction, question)
|
|
||||||
await self.handle_message(event)
|
|
||||||
# The response is sent via the normal send() flow
|
|
||||||
# Send a followup to close the interaction if needed
|
|
||||||
try:
|
|
||||||
await interaction.followup.send("Processing complete~", ephemeral=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Discord followup failed: %s", e)
|
|
||||||
|
|
||||||
@tree.command(name="new", description="Start a new conversation")
|
@tree.command(name="new", description="Start a new conversation")
|
||||||
async def slash_new(interaction: discord.Interaction):
|
async def slash_new(interaction: discord.Interaction):
|
||||||
await self._run_simple_slash(interaction, "/reset", "New conversation started~")
|
await self._run_simple_slash(interaction, "/reset", "New conversation started~")
|
||||||
|
|
@ -1414,10 +1402,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
|
event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
|
||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
try:
|
|
||||||
await interaction.followup.send("Done~", ephemeral=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Discord followup failed: %s", e)
|
|
||||||
|
|
||||||
@tree.command(name="personality", description="Set a personality")
|
@tree.command(name="personality", description="Set a personality")
|
||||||
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
||||||
|
|
@ -1493,10 +1477,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
event = self._build_slash_event(interaction, f"/voice {mode}".strip())
|
event = self._build_slash_event(interaction, f"/voice {mode}".strip())
|
||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
try:
|
|
||||||
await interaction.followup.send("Done~", ephemeral=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Discord followup failed: %s", e)
|
|
||||||
|
|
||||||
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
||||||
async def slash_update(interaction: discord.Interaction):
|
async def slash_update(interaction: discord.Interaction):
|
||||||
|
|
|
||||||
|
|
@ -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 []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -601,6 +603,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)
|
||||||
|
|
||||||
|
|
@ -2122,23 +2126,41 @@ class GatewayRunner:
|
||||||
error_detail = str(e)[:300] if str(e) else "no details available"
|
error_detail = str(e)[:300] if str(e) else "no details available"
|
||||||
status_hint = ""
|
status_hint = ""
|
||||||
status_code = getattr(e, "status_code", None)
|
status_code = getattr(e, "status_code", None)
|
||||||
|
_hist_len = len(history) if 'history' in locals() else 0
|
||||||
if status_code == 401:
|
if status_code == 401:
|
||||||
status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials."
|
status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials."
|
||||||
elif status_code == 429:
|
elif status_code == 429:
|
||||||
status_hint = " You are being rate-limited. Please wait a moment and try again."
|
# Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit
|
||||||
|
_err_body = getattr(e, "response", None)
|
||||||
|
_err_json = {}
|
||||||
|
try:
|
||||||
|
if _err_body is not None:
|
||||||
|
_err_json = _err_body.json().get("error", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _err_json.get("type") == "usage_limit_reached":
|
||||||
|
_resets_in = _err_json.get("resets_in_seconds")
|
||||||
|
if _resets_in and _resets_in > 0:
|
||||||
|
import math
|
||||||
|
_hours = math.ceil(_resets_in / 3600)
|
||||||
|
status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h."
|
||||||
|
else:
|
||||||
|
status_hint = " Your plan's usage limit has been reached. Please wait until it resets."
|
||||||
|
else:
|
||||||
|
status_hint = " You are being rate-limited. Please wait a moment and try again."
|
||||||
elif status_code == 529:
|
elif status_code == 529:
|
||||||
status_hint = " The API is temporarily overloaded. Please try again shortly."
|
status_hint = " The API is temporarily overloaded. Please try again shortly."
|
||||||
elif status_code == 400:
|
elif status_code in (400, 500):
|
||||||
# 400 with a large session is almost always a context overflow.
|
# 400 with a large session is context overflow.
|
||||||
# Give specific guidance instead of a generic error. (#1630)
|
# 500 with a large session often means the payload is too large
|
||||||
_hist_len = len(history) if 'history' in locals() else 0
|
# for the API to process — treat it the same way.
|
||||||
if _hist_len > 50:
|
if _hist_len > 50:
|
||||||
return (
|
return (
|
||||||
"⚠️ Session too large for the model's context window.\n"
|
"⚠️ Session too large for the model's context window.\n"
|
||||||
"Use /compact to compress the conversation, or "
|
"Use /compact to compress the conversation, or "
|
||||||
"/reset to start fresh."
|
"/reset to start fresh."
|
||||||
)
|
)
|
||||||
else:
|
elif status_code == 400:
|
||||||
status_hint = " The request was rejected by the API."
|
status_hint = " The request was rejected by the API."
|
||||||
return (
|
return (
|
||||||
f"Sorry, I encountered an error ({error_type}).\n"
|
f"Sorry, I encountered an error ({error_type}).\n"
|
||||||
|
|
|
||||||
|
|
@ -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=("GITHUB_TOKEN", "GH_TOKEN"),
|
||||||
|
),
|
||||||
|
"copilot-acp": ProviderConfig(
|
||||||
|
id="copilot-acp",
|
||||||
|
name="GitHub Copilot ACP",
|
||||||
|
auth_type="external_process",
|
||||||
|
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
|
||||||
|
base_url_env_var="COPILOT_ACP_BASE_URL",
|
||||||
|
),
|
||||||
"zai": ProviderConfig(
|
"zai": ProviderConfig(
|
||||||
id="zai",
|
id="zai",
|
||||||
name="Z.AI / GLM",
|
name="Z.AI / GLM",
|
||||||
|
|
@ -222,6 +239,62 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
|
||||||
return default_url
|
return default_url
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_cli_candidates() -> list[str]:
|
||||||
|
"""Return candidate ``gh`` binary paths, including common Homebrew installs."""
|
||||||
|
candidates: list[str] = []
|
||||||
|
|
||||||
|
resolved = shutil.which("gh")
|
||||||
|
if resolved:
|
||||||
|
candidates.append(resolved)
|
||||||
|
|
||||||
|
for candidate in (
|
||||||
|
"/opt/homebrew/bin/gh",
|
||||||
|
"/usr/local/bin/gh",
|
||||||
|
str(Path.home() / ".local" / "bin" / "gh"),
|
||||||
|
):
|
||||||
|
if candidate in candidates:
|
||||||
|
continue
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _try_gh_cli_token() -> Optional[str]:
|
||||||
|
"""Return a token from ``gh auth token`` when the GitHub CLI is available."""
|
||||||
|
for gh_path in _gh_cli_candidates():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[gh_path, "auth", "token"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||||
|
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
|
||||||
|
continue
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_key_provider_secret(
|
||||||
|
provider_id: str, pconfig: ProviderConfig
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Resolve an API-key provider's token and indicate where it came from."""
|
||||||
|
for env_var in pconfig.api_key_env_vars:
|
||||||
|
val = os.getenv(env_var, "").strip()
|
||||||
|
if val:
|
||||||
|
return val, env_var
|
||||||
|
|
||||||
|
if provider_id == "copilot":
|
||||||
|
token = _try_gh_cli_token()
|
||||||
|
if token:
|
||||||
|
return token, "gh auth token"
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Z.AI Endpoint Detection
|
# Z.AI Endpoint Detection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -572,6 +645,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 +687,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 +1560,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 +1583,36 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||||
|
"""Status snapshot for providers that run a local subprocess."""
|
||||||
|
pconfig = PROVIDER_REGISTRY.get(provider_id)
|
||||||
|
if not pconfig or pconfig.auth_type != "external_process":
|
||||||
|
return {"configured": False}
|
||||||
|
|
||||||
|
command = (
|
||||||
|
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||||
|
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||||
|
or "copilot"
|
||||||
|
)
|
||||||
|
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||||
|
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||||
|
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
|
||||||
|
if not base_url:
|
||||||
|
base_url = pconfig.inference_base_url
|
||||||
|
|
||||||
|
resolved_command = shutil.which(command) if command else None
|
||||||
|
return {
|
||||||
|
"configured": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||||
|
"provider": provider_id,
|
||||||
|
"name": pconfig.name,
|
||||||
|
"command": command,
|
||||||
|
"args": args,
|
||||||
|
"resolved_command": resolved_command,
|
||||||
|
"base_url": base_url,
|
||||||
|
"logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
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 +1620,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 +1644,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 +1665,46 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||||||
|
"""Resolve runtime details for local subprocess-backed providers."""
|
||||||
|
pconfig = PROVIDER_REGISTRY.get(provider_id)
|
||||||
|
if not pconfig or pconfig.auth_type != "external_process":
|
||||||
|
raise AuthError(
|
||||||
|
f"Provider '{provider_id}' is not an external-process provider.",
|
||||||
|
provider=provider_id,
|
||||||
|
code="invalid_provider",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
|
||||||
|
if not base_url:
|
||||||
|
base_url = pconfig.inference_base_url
|
||||||
|
|
||||||
|
command = (
|
||||||
|
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
||||||
|
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
||||||
|
or "copilot"
|
||||||
|
)
|
||||||
|
raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
||||||
|
args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
|
||||||
|
resolved_command = shutil.which(command) if command else None
|
||||||
|
if not resolved_command and not base_url.startswith("acp+tcp://"):
|
||||||
|
raise AuthError(
|
||||||
|
f"Could not find the Copilot CLI command '{command}'. "
|
||||||
|
"Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.",
|
||||||
|
provider=provider_id,
|
||||||
|
code="missing_copilot_cli",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": provider_id,
|
||||||
|
"api_key": "copilot-acp",
|
||||||
|
"base_url": base_url.rstrip("/"),
|
||||||
|
"command": resolved_command or command,
|
||||||
|
"args": args,
|
||||||
|
"source": "process",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# External credential detection
|
# External credential detection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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,331 @@ _PROVIDER_MODELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_reasoning_effort(config) -> str:
|
||||||
|
agent_cfg = config.get("agent")
|
||||||
|
if isinstance(agent_cfg, dict):
|
||||||
|
return str(agent_cfg.get("reasoning_effort") or "").strip().lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_reasoning_effort(config, effort: str) -> None:
|
||||||
|
agent_cfg = config.get("agent")
|
||||||
|
if not isinstance(agent_cfg, dict):
|
||||||
|
agent_cfg = {}
|
||||||
|
config["agent"] = agent_cfg
|
||||||
|
agent_cfg["reasoning_effort"] = effort
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||||
|
"""Prompt for a reasoning effort. Returns effort, 'none', or None to keep current."""
|
||||||
|
ordered = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
|
||||||
|
if not ordered:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _label(effort):
|
||||||
|
if effort == current_effort:
|
||||||
|
return f"{effort} ← currently in use"
|
||||||
|
return effort
|
||||||
|
|
||||||
|
disable_label = "Disable reasoning"
|
||||||
|
skip_label = "Skip (keep current)"
|
||||||
|
|
||||||
|
if current_effort == "none":
|
||||||
|
default_idx = len(ordered)
|
||||||
|
elif current_effort in ordered:
|
||||||
|
default_idx = ordered.index(current_effort)
|
||||||
|
elif "medium" in ordered:
|
||||||
|
default_idx = ordered.index("medium")
|
||||||
|
else:
|
||||||
|
default_idx = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from simple_term_menu import TerminalMenu
|
||||||
|
|
||||||
|
choices = [f" {_label(effort)}" for effort in ordered]
|
||||||
|
choices.append(f" {disable_label}")
|
||||||
|
choices.append(f" {skip_label}")
|
||||||
|
menu = TerminalMenu(
|
||||||
|
choices,
|
||||||
|
cursor_index=default_idx,
|
||||||
|
menu_cursor="-> ",
|
||||||
|
menu_cursor_style=("fg_green", "bold"),
|
||||||
|
menu_highlight_style=("fg_green",),
|
||||||
|
cycle_cursor=True,
|
||||||
|
clear_screen=False,
|
||||||
|
title="Select reasoning effort:",
|
||||||
|
)
|
||||||
|
idx = menu.show()
|
||||||
|
if idx is None:
|
||||||
|
return None
|
||||||
|
print()
|
||||||
|
if idx < len(ordered):
|
||||||
|
return ordered[idx]
|
||||||
|
if idx == len(ordered):
|
||||||
|
return "none"
|
||||||
|
return None
|
||||||
|
except (ImportError, NotImplementedError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("Select reasoning effort:")
|
||||||
|
for i, effort in enumerate(ordered, 1):
|
||||||
|
print(f" {i}. {_label(effort)}")
|
||||||
|
n = len(ordered)
|
||||||
|
print(f" {n + 1}. {disable_label}")
|
||||||
|
print(f" {n + 2}. {skip_label}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip()
|
||||||
|
if not choice:
|
||||||
|
return None
|
||||||
|
idx = int(choice)
|
||||||
|
if 1 <= idx <= n:
|
||||||
|
return ordered[idx - 1]
|
||||||
|
if idx == n + 1:
|
||||||
|
return "none"
|
||||||
|
if idx == n + 2:
|
||||||
|
return None
|
||||||
|
print(f"Please enter 1-{n + 2}")
|
||||||
|
except ValueError:
|
||||||
|
print("Please enter a number")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _model_flow_copilot(config, current_model=""):
|
||||||
|
"""GitHub Copilot flow using env vars or ``gh auth token``."""
|
||||||
|
from hermes_cli.auth import (
|
||||||
|
PROVIDER_REGISTRY,
|
||||||
|
_prompt_model_selection,
|
||||||
|
_save_model_choice,
|
||||||
|
deactivate_provider,
|
||||||
|
resolve_api_key_provider_credentials,
|
||||||
|
)
|
||||||
|
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||||
|
from hermes_cli.models import (
|
||||||
|
fetch_api_models,
|
||||||
|
fetch_github_model_catalog,
|
||||||
|
github_model_reasoning_efforts,
|
||||||
|
copilot_model_api_mode,
|
||||||
|
normalize_copilot_model_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_id = "copilot"
|
||||||
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||||
|
|
||||||
|
creds = resolve_api_key_provider_credentials(provider_id)
|
||||||
|
api_key = creds.get("api_key", "")
|
||||||
|
source = creds.get("source", "")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
print("No GitHub token configured for GitHub Copilot.")
|
||||||
|
print(" Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.")
|
||||||
|
try:
|
||||||
|
new_key = input("GITHUB_TOKEN (or Enter to cancel): ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
if not new_key:
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
save_env_value("GITHUB_TOKEN", new_key)
|
||||||
|
print("GitHub token saved.")
|
||||||
|
print()
|
||||||
|
creds = resolve_api_key_provider_credentials(provider_id)
|
||||||
|
api_key = creds.get("api_key", "")
|
||||||
|
source = creds.get("source", "")
|
||||||
|
else:
|
||||||
|
if source in ("GITHUB_TOKEN", "GH_TOKEN"):
|
||||||
|
print(f" GitHub token: {api_key[:8]}... ✓ ({source})")
|
||||||
|
elif source == "gh auth token":
|
||||||
|
print(" GitHub token: ✓ (from `gh auth token`)")
|
||||||
|
else:
|
||||||
|
print(" GitHub token: ✓")
|
||||||
|
print()
|
||||||
|
|
||||||
|
effective_base = pconfig.inference_base_url
|
||||||
|
|
||||||
|
catalog = fetch_github_model_catalog(api_key)
|
||||||
|
live_models = [item.get("id", "") for item in catalog if item.get("id")] if catalog else fetch_api_models(api_key, effective_base)
|
||||||
|
normalized_current_model = normalize_copilot_model_id(
|
||||||
|
current_model,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=api_key,
|
||||||
|
) or current_model
|
||||||
|
if live_models:
|
||||||
|
model_list = [model_id for model_id in live_models if model_id]
|
||||||
|
print(f" Found {len(model_list)} model(s) from GitHub Copilot")
|
||||||
|
else:
|
||||||
|
model_list = _PROVIDER_MODELS.get(provider_id, [])
|
||||||
|
if model_list:
|
||||||
|
print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.")
|
||||||
|
print(' Use "Enter custom model name" if you do not see your model.')
|
||||||
|
|
||||||
|
if model_list:
|
||||||
|
selected = _prompt_model_selection(model_list, current_model=normalized_current_model)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
selected = input("Model name: ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
selected = None
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
selected = normalize_copilot_model_id(
|
||||||
|
selected,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=api_key,
|
||||||
|
) or selected
|
||||||
|
# Clear stale custom-endpoint overrides so the Copilot provider wins cleanly.
|
||||||
|
if get_env_value("OPENAI_BASE_URL"):
|
||||||
|
save_env_value("OPENAI_BASE_URL", "")
|
||||||
|
save_env_value("OPENAI_API_KEY", "")
|
||||||
|
|
||||||
|
initial_cfg = load_config()
|
||||||
|
current_effort = _current_reasoning_effort(initial_cfg)
|
||||||
|
reasoning_efforts = github_model_reasoning_efforts(
|
||||||
|
selected,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
selected_effort = None
|
||||||
|
if reasoning_efforts:
|
||||||
|
print(f" {selected} supports reasoning controls.")
|
||||||
|
selected_effort = _prompt_reasoning_effort_selection(
|
||||||
|
reasoning_efforts, current_effort=current_effort
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_model_choice(selected)
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
model = cfg.get("model")
|
||||||
|
if not isinstance(model, dict):
|
||||||
|
model = {"default": model} if model else {}
|
||||||
|
cfg["model"] = model
|
||||||
|
model["provider"] = provider_id
|
||||||
|
model["base_url"] = effective_base
|
||||||
|
model["api_mode"] = copilot_model_api_mode(
|
||||||
|
selected,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
if selected_effort is not None:
|
||||||
|
_set_reasoning_effort(cfg, selected_effort)
|
||||||
|
save_config(cfg)
|
||||||
|
deactivate_provider()
|
||||||
|
|
||||||
|
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||||
|
if reasoning_efforts:
|
||||||
|
if selected_effort == "none":
|
||||||
|
print("Reasoning disabled for this model.")
|
||||||
|
elif selected_effort:
|
||||||
|
print(f"Reasoning effort set to: {selected_effort}")
|
||||||
|
else:
|
||||||
|
print("No change.")
|
||||||
|
|
||||||
|
|
||||||
|
def _model_flow_copilot_acp(config, current_model=""):
|
||||||
|
"""GitHub Copilot ACP flow using the local Copilot CLI."""
|
||||||
|
from hermes_cli.auth import (
|
||||||
|
PROVIDER_REGISTRY,
|
||||||
|
_prompt_model_selection,
|
||||||
|
_save_model_choice,
|
||||||
|
deactivate_provider,
|
||||||
|
get_external_process_provider_status,
|
||||||
|
resolve_api_key_provider_credentials,
|
||||||
|
resolve_external_process_provider_credentials,
|
||||||
|
)
|
||||||
|
from hermes_cli.models import (
|
||||||
|
fetch_github_model_catalog,
|
||||||
|
normalize_copilot_model_id,
|
||||||
|
)
|
||||||
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
||||||
|
del config
|
||||||
|
|
||||||
|
provider_id = "copilot-acp"
|
||||||
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||||
|
|
||||||
|
status = get_external_process_provider_status(provider_id)
|
||||||
|
resolved_command = status.get("resolved_command") or status.get("command") or "copilot"
|
||||||
|
effective_base = status.get("base_url") or pconfig.inference_base_url
|
||||||
|
|
||||||
|
print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.")
|
||||||
|
print(" Hermes currently starts its own ACP subprocess for each request.")
|
||||||
|
print(" Hermes uses your selected model as a hint for the Copilot ACP session.")
|
||||||
|
print(f" Command: {resolved_command}")
|
||||||
|
print(f" Backend marker: {effective_base}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = resolve_external_process_provider_credentials(provider_id)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ⚠ {exc}")
|
||||||
|
print(" Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere.")
|
||||||
|
return
|
||||||
|
|
||||||
|
effective_base = creds.get("base_url") or effective_base
|
||||||
|
|
||||||
|
catalog_api_key = ""
|
||||||
|
try:
|
||||||
|
catalog_creds = resolve_api_key_provider_credentials("copilot")
|
||||||
|
catalog_api_key = catalog_creds.get("api_key", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
catalog = fetch_github_model_catalog(catalog_api_key)
|
||||||
|
normalized_current_model = normalize_copilot_model_id(
|
||||||
|
current_model,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=catalog_api_key,
|
||||||
|
) or current_model
|
||||||
|
|
||||||
|
if catalog:
|
||||||
|
model_list = [item.get("id", "") for item in catalog if item.get("id")]
|
||||||
|
print(f" Found {len(model_list)} model(s) from GitHub Copilot")
|
||||||
|
else:
|
||||||
|
model_list = _PROVIDER_MODELS.get("copilot", [])
|
||||||
|
if model_list:
|
||||||
|
print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.")
|
||||||
|
print(' Use "Enter custom model name" if you do not see your model.')
|
||||||
|
|
||||||
|
if model_list:
|
||||||
|
selected = _prompt_model_selection(
|
||||||
|
model_list,
|
||||||
|
current_model=normalized_current_model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
selected = input("Model name: ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
selected = None
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
print("No change.")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = normalize_copilot_model_id(
|
||||||
|
selected,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=catalog_api_key,
|
||||||
|
) or selected
|
||||||
|
_save_model_choice(selected)
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
model = cfg.get("model")
|
||||||
|
if not isinstance(model, dict):
|
||||||
|
model = {"default": model} if model else {}
|
||||||
|
cfg["model"] = model
|
||||||
|
model["provider"] = provider_id
|
||||||
|
model["base_url"] = effective_base
|
||||||
|
model["api_mode"] = "chat_completions"
|
||||||
|
save_config(cfg)
|
||||||
|
deactivate_provider()
|
||||||
|
|
||||||
|
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||||
|
|
||||||
|
|
||||||
def _model_flow_kimi(config, current_model=""):
|
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 +3005,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"),
|
||||||
|
|
@ -46,6 +56,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",
|
||||||
|
|
@ -160,7 +189,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",
|
||||||
|
|
@ -180,6 +211,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",
|
||||||
|
|
@ -233,7 +270,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",
|
||||||
|
|
@ -454,6 +491,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.
|
||||||
|
|
||||||
|
|
@ -467,6 +515,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:
|
||||||
|
|
@ -545,6 +602,274 @@ 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]:
|
||||||
|
return {
|
||||||
|
"Editor-Version": COPILOT_EDITOR_VERSION,
|
||||||
|
"User-Agent": "HermesAgent/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool:
|
||||||
|
model_id = str(item.get("id") or "").strip()
|
||||||
|
if not model_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if item.get("model_picker_enabled") is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
capabilities = item.get("capabilities")
|
||||||
|
if isinstance(capabilities, dict):
|
||||||
|
model_type = str(capabilities.get("type") or "").strip().lower()
|
||||||
|
if model_type and model_type != "chat":
|
||||||
|
return False
|
||||||
|
|
||||||
|
supported_endpoints = item.get("supported_endpoints")
|
||||||
|
if isinstance(supported_endpoints, list):
|
||||||
|
normalized_endpoints = {
|
||||||
|
str(endpoint).strip()
|
||||||
|
for endpoint in supported_endpoints
|
||||||
|
if str(endpoint).strip()
|
||||||
|
}
|
||||||
|
if normalized_endpoints and not normalized_endpoints.intersection(
|
||||||
|
{"/chat/completions", "/responses", "/v1/messages"}
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_github_model_catalog(
|
||||||
|
api_key: Optional[str] = None, timeout: float = 5.0
|
||||||
|
) -> Optional[list[dict[str, Any]]]:
|
||||||
|
"""Fetch the live GitHub Copilot model catalog for this account."""
|
||||||
|
attempts: list[dict[str, str]] = []
|
||||||
|
if api_key:
|
||||||
|
attempts.append({
|
||||||
|
**copilot_default_headers(),
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
})
|
||||||
|
attempts.append(copilot_default_headers())
|
||||||
|
|
||||||
|
for headers in attempts:
|
||||||
|
req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
items = _payload_items(data)
|
||||||
|
models: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
if not _copilot_catalog_item_is_text_model(item):
|
||||||
|
continue
|
||||||
|
model_id = str(item.get("id") or "").strip()
|
||||||
|
if not model_id or model_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(model_id)
|
||||||
|
models.append(item)
|
||||||
|
if models:
|
||||||
|
return models
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_github_models_base_url(base_url: Optional[str]) -> bool:
|
||||||
|
normalized = (base_url or "").strip().rstrip("/").lower()
|
||||||
|
return (
|
||||||
|
normalized.startswith(COPILOT_BASE_URL)
|
||||||
|
or normalized.startswith("https://models.github.ai/inference")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
|
||||||
|
catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
|
||||||
|
if not catalog:
|
||||||
|
return None
|
||||||
|
return [item.get("id", "") for item in catalog if item.get("id")]
|
||||||
|
|
||||||
|
|
||||||
|
_COPILOT_MODEL_ALIASES = {
|
||||||
|
"openai/gpt-5": "gpt-5-mini",
|
||||||
|
"openai/gpt-5-chat": "gpt-5-mini",
|
||||||
|
"openai/gpt-5-mini": "gpt-5-mini",
|
||||||
|
"openai/gpt-5-nano": "gpt-5-mini",
|
||||||
|
"openai/gpt-4.1": "gpt-4.1",
|
||||||
|
"openai/gpt-4.1-mini": "gpt-4.1",
|
||||||
|
"openai/gpt-4.1-nano": "gpt-4.1",
|
||||||
|
"openai/gpt-4o": "gpt-4o",
|
||||||
|
"openai/gpt-4o-mini": "gpt-4o-mini",
|
||||||
|
"openai/o1": "gpt-5.2",
|
||||||
|
"openai/o1-mini": "gpt-5-mini",
|
||||||
|
"openai/o1-preview": "gpt-5.2",
|
||||||
|
"openai/o3": "gpt-5.3-codex",
|
||||||
|
"openai/o3-mini": "gpt-5-mini",
|
||||||
|
"openai/o4-mini": "gpt-5-mini",
|
||||||
|
"anthropic/claude-opus-4.6": "claude-opus-4.6",
|
||||||
|
"anthropic/claude-sonnet-4.6": "claude-sonnet-4.6",
|
||||||
|
"anthropic/claude-sonnet-4.5": "claude-sonnet-4.5",
|
||||||
|
"anthropic/claude-haiku-4.5": "claude-haiku-4.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _copilot_catalog_ids(
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
) -> set[str]:
|
||||||
|
if catalog is None and api_key:
|
||||||
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
||||||
|
if not catalog:
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
str(item.get("id") or "").strip()
|
||||||
|
for item in catalog
|
||||||
|
if str(item.get("id") or "").strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_copilot_model_id(
|
||||||
|
model_id: Optional[str],
|
||||||
|
*,
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
raw = str(model_id or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key)
|
||||||
|
alias = _COPILOT_MODEL_ALIASES.get(raw)
|
||||||
|
if alias:
|
||||||
|
return alias
|
||||||
|
|
||||||
|
candidates = [raw]
|
||||||
|
if "/" in raw:
|
||||||
|
candidates.append(raw.split("/", 1)[1].strip())
|
||||||
|
|
||||||
|
if raw.endswith("-mini"):
|
||||||
|
candidates.append(raw[:-5])
|
||||||
|
if raw.endswith("-nano"):
|
||||||
|
candidates.append(raw[:-5])
|
||||||
|
if raw.endswith("-chat"):
|
||||||
|
candidates.append(raw[:-5])
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate or candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
if candidate in _COPILOT_MODEL_ALIASES:
|
||||||
|
return _COPILOT_MODEL_ALIASES[candidate]
|
||||||
|
if candidate in catalog_ids:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
if "/" in raw:
|
||||||
|
return raw.split("/", 1)[1].strip()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
|
||||||
|
raw = (model_id or "").strip().lower()
|
||||||
|
if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")):
|
||||||
|
return list(COPILOT_REASONING_EFFORTS_O_SERIES)
|
||||||
|
normalized = normalize_copilot_model_id(model_id).lower()
|
||||||
|
if normalized.startswith("gpt-5"):
|
||||||
|
return list(COPILOT_REASONING_EFFORTS_GPT5)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def copilot_model_api_mode(
|
||||||
|
model_id: Optional[str],
|
||||||
|
*,
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
|
||||||
|
if not normalized:
|
||||||
|
return "chat_completions"
|
||||||
|
|
||||||
|
if catalog is None and api_key:
|
||||||
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
||||||
|
|
||||||
|
catalog_entry = None
|
||||||
|
if catalog:
|
||||||
|
catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
|
||||||
|
|
||||||
|
if isinstance(catalog_entry, dict):
|
||||||
|
supported_endpoints = {
|
||||||
|
str(endpoint).strip()
|
||||||
|
for endpoint in (catalog_entry.get("supported_endpoints") or [])
|
||||||
|
if str(endpoint).strip()
|
||||||
|
}
|
||||||
|
if "/chat/completions" in supported_endpoints:
|
||||||
|
return "chat_completions"
|
||||||
|
if "/responses" in supported_endpoints:
|
||||||
|
return "codex_responses"
|
||||||
|
if "/v1/messages" in supported_endpoints:
|
||||||
|
return "anthropic_messages"
|
||||||
|
|
||||||
|
if normalized.startswith(("gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex")):
|
||||||
|
return "codex_responses"
|
||||||
|
return "chat_completions"
|
||||||
|
|
||||||
|
|
||||||
|
def github_model_reasoning_efforts(
|
||||||
|
model_id: Optional[str],
|
||||||
|
*,
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return supported reasoning-effort levels for a Copilot-visible model."""
|
||||||
|
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
catalog_entry = None
|
||||||
|
if catalog is not None:
|
||||||
|
catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
|
||||||
|
elif api_key:
|
||||||
|
fetched_catalog = fetch_github_model_catalog(api_key=api_key)
|
||||||
|
if fetched_catalog:
|
||||||
|
catalog_entry = next((item for item in fetched_catalog if item.get("id") == normalized), None)
|
||||||
|
|
||||||
|
if catalog_entry is not None:
|
||||||
|
capabilities = catalog_entry.get("capabilities")
|
||||||
|
if isinstance(capabilities, dict):
|
||||||
|
supports = capabilities.get("supports")
|
||||||
|
if isinstance(supports, dict):
|
||||||
|
efforts = supports.get("reasoning_effort")
|
||||||
|
if isinstance(efforts, list):
|
||||||
|
normalized_efforts = [
|
||||||
|
str(effort).strip().lower()
|
||||||
|
for effort in efforts
|
||||||
|
if str(effort).strip()
|
||||||
|
]
|
||||||
|
return list(dict.fromkeys(normalized_efforts))
|
||||||
|
return []
|
||||||
|
legacy_capabilities = {
|
||||||
|
str(capability).strip().lower()
|
||||||
|
for capability in catalog_entry.get("capabilities", [])
|
||||||
|
if str(capability).strip()
|
||||||
|
}
|
||||||
|
if "reasoning" not in legacy_capabilities:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _github_reasoning_efforts_for_model_id(str(model_id or normalized))
|
||||||
|
|
||||||
|
|
||||||
def probe_api_models(
|
def probe_api_models(
|
||||||
api_key: Optional[str],
|
api_key: Optional[str],
|
||||||
base_url: Optional[str],
|
base_url: Optional[str],
|
||||||
|
|
@ -561,6 +886,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:
|
||||||
|
|
@ -574,6 +909,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"
|
||||||
|
|
@ -664,6 +1001,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 {
|
||||||
|
|
@ -685,7 +1028,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,
|
||||||
|
|
@ -734,7 +1077,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.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
"minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
||||||
|
|
@ -64,6 +83,59 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_reasoning_effort(config: Dict[str, Any]) -> str:
|
||||||
|
agent_cfg = config.get("agent")
|
||||||
|
if isinstance(agent_cfg, dict):
|
||||||
|
return str(agent_cfg.get("reasoning_effort") or "").strip().lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
|
||||||
|
agent_cfg = config.get("agent")
|
||||||
|
if not isinstance(agent_cfg, dict):
|
||||||
|
agent_cfg = {}
|
||||||
|
config["agent"] = agent_cfg
|
||||||
|
agent_cfg["reasoning_effort"] = effort
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_copilot_reasoning_selection(
|
||||||
|
config: Dict[str, Any],
|
||||||
|
model_id: str,
|
||||||
|
prompt_choice,
|
||||||
|
*,
|
||||||
|
catalog: Optional[list[dict[str, Any]]] = None,
|
||||||
|
api_key: str = "",
|
||||||
|
) -> None:
|
||||||
|
from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id
|
||||||
|
|
||||||
|
normalized_model = normalize_copilot_model_id(
|
||||||
|
model_id,
|
||||||
|
catalog=catalog,
|
||||||
|
api_key=api_key,
|
||||||
|
) or model_id
|
||||||
|
efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key)
|
||||||
|
if not efforts:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_effort = _current_reasoning_effort(config)
|
||||||
|
choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"]
|
||||||
|
|
||||||
|
if current_effort == "none":
|
||||||
|
default_idx = len(efforts)
|
||||||
|
elif current_effort in efforts:
|
||||||
|
default_idx = efforts.index(current_effort)
|
||||||
|
elif "medium" in efforts:
|
||||||
|
default_idx = efforts.index("medium")
|
||||||
|
else:
|
||||||
|
default_idx = len(choices) - 1
|
||||||
|
|
||||||
|
effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx)
|
||||||
|
if effort_idx < len(efforts):
|
||||||
|
_set_reasoning_effort(config, efforts[effort_idx])
|
||||||
|
elif effort_idx == len(efforts):
|
||||||
|
_set_reasoning_effort(config, "none")
|
||||||
|
|
||||||
|
|
||||||
def _setup_provider_model_selection(config, provider_id, current_model, prompt_choice, prompt_fn):
|
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
|
|
@ -274,6 +274,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,
|
||||||
|
|
@ -379,6 +383,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":
|
||||||
|
|
@ -572,6 +578,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"] = {
|
||||||
|
|
@ -579,6 +588,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",
|
||||||
|
|
@ -2685,10 +2698,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",
|
||||||
|
|
@ -3544,6 +3570,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
|
||||||
|
|
@ -3561,13 +3592,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:
|
||||||
|
|
@ -3638,6 +3679,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.
|
||||||
|
|
@ -3648,19 +3693,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:
|
||||||
|
|
@ -3683,6 +3733,13 @@ class AIAgent:
|
||||||
return True
|
return True
|
||||||
if "ai-gateway.vercel.sh" in base_url:
|
if "ai-gateway.vercel.sh" in base_url:
|
||||||
return True
|
return True
|
||||||
|
if "models.github.ai" in base_url or "api.githubcopilot.com" in base_url:
|
||||||
|
try:
|
||||||
|
from hermes_cli.models import github_model_reasoning_efforts
|
||||||
|
|
||||||
|
return bool(github_model_reasoning_efforts(self.model))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
if "openrouter" not in base_url:
|
if "openrouter" not in base_url:
|
||||||
return False
|
return False
|
||||||
if "api.mistral.ai" in base_url:
|
if "api.mistral.ai" in base_url:
|
||||||
|
|
@ -3699,6 +3756,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 (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job
|
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job, SILENT_MARKER
|
||||||
|
|
||||||
|
|
||||||
class TestResolveOrigin:
|
class TestResolveOrigin:
|
||||||
|
|
@ -449,3 +449,97 @@ class TestRunJobSkillBacked:
|
||||||
assert "Instructions for blogwatcher." in prompt_arg
|
assert "Instructions for blogwatcher." in prompt_arg
|
||||||
assert "Instructions for find-nearby." in prompt_arg
|
assert "Instructions for find-nearby." in prompt_arg
|
||||||
assert "Combine the results." in prompt_arg
|
assert "Combine the results." in prompt_arg
|
||||||
|
|
||||||
|
|
||||||
|
class TestSilentDelivery:
|
||||||
|
"""Verify that [SILENT] responses suppress delivery while still saving output."""
|
||||||
|
|
||||||
|
def _make_job(self):
|
||||||
|
return {
|
||||||
|
"id": "monitor-job",
|
||||||
|
"name": "monitor",
|
||||||
|
"deliver": "origin",
|
||||||
|
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_normal_response_delivers(self):
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \
|
||||||
|
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
from cron.scheduler import tick
|
||||||
|
tick(verbose=False)
|
||||||
|
deliver_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_silent_response_suppresses_delivery(self, caplog):
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
|
||||||
|
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
from cron.scheduler import tick
|
||||||
|
with caplog.at_level(logging.INFO, logger="cron.scheduler"):
|
||||||
|
tick(verbose=False)
|
||||||
|
deliver_mock.assert_not_called()
|
||||||
|
assert any(SILENT_MARKER in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
def test_silent_with_note_suppresses_delivery(self):
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT] No changes detected", None)), \
|
||||||
|
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
from cron.scheduler import tick
|
||||||
|
tick(verbose=False)
|
||||||
|
deliver_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_silent_is_case_insensitive(self):
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \
|
||||||
|
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
from cron.scheduler import tick
|
||||||
|
tick(verbose=False)
|
||||||
|
deliver_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_failed_job_always_delivers(self):
|
||||||
|
"""Failed jobs deliver regardless of [SILENT] in output."""
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(False, "# output", "", "some error")), \
|
||||||
|
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
from cron.scheduler import tick
|
||||||
|
tick(verbose=False)
|
||||||
|
deliver_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_output_saved_even_when_delivery_suppressed(self):
|
||||||
|
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||||
|
patch("cron.scheduler.run_job", return_value=(True, "# full output", "[SILENT]", None)), \
|
||||||
|
patch("cron.scheduler.save_job_output") as save_mock, \
|
||||||
|
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||||
|
patch("cron.scheduler.mark_job_run"):
|
||||||
|
save_mock.return_value = "/tmp/out.md"
|
||||||
|
from cron.scheduler import tick
|
||||||
|
tick(verbose=False)
|
||||||
|
save_mock.assert_called_once_with("monitor-job", "# full output")
|
||||||
|
deliver_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildJobPromptSilentHint:
|
||||||
|
"""Verify _build_job_prompt always injects [SILENT] guidance."""
|
||||||
|
|
||||||
|
def test_hint_always_present(self):
|
||||||
|
from cron.scheduler import _build_job_prompt
|
||||||
|
job = {"prompt": "Check for updates"}
|
||||||
|
result = _build_job_prompt(job)
|
||||||
|
assert "[SILENT]" in result
|
||||||
|
assert "Check for updates" in result
|
||||||
|
|
||||||
|
def test_hint_present_even_without_prompt(self):
|
||||||
|
from cron.scheduler import _build_job_prompt
|
||||||
|
job = {"prompt": ""}
|
||||||
|
result = _build_job_prompt(job)
|
||||||
|
assert "[SILENT]" in result
|
||||||
|
|
|
||||||
|
|
@ -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,82 @@ 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_prefers_responses(self):
|
||||||
|
catalog = [{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
"capabilities": {"type": "chat"},
|
||||||
|
}]
|
||||||
|
assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"
|
||||||
|
|
||||||
|
|
||||||
# -- validate — format checks -----------------------------------------------
|
# -- 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 == ("GITHUB_TOKEN", "GH_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.auth._try_gh_cli_token", lambda: "gh-cli-token")
|
||||||
|
status = get_api_key_provider_status("copilot")
|
||||||
|
assert status["configured"] is True
|
||||||
|
assert status["logged_in"] is True
|
||||||
|
assert status["key_source"] == "gh auth token"
|
||||||
|
assert status["base_url"] == "https://api.githubcopilot.com"
|
||||||
|
|
||||||
def test_get_auth_status_dispatches_to_api_key(self, monkeypatch):
|
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.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||||
|
creds = resolve_api_key_provider_credentials("copilot")
|
||||||
|
assert creds["provider"] == "copilot"
|
||||||
|
assert creds["api_key"] == "gh-cli-secret"
|
||||||
|
assert creds["base_url"] == "https://api.githubcopilot.com"
|
||||||
|
assert creds["source"] == "gh auth token"
|
||||||
|
|
||||||
|
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.os.path.isfile",
|
||||||
|
lambda path: path == "/opt/homebrew/bin/gh",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.os.access",
|
||||||
|
lambda path, mode: path == "/opt/homebrew/bin/gh" and mode == os.X_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "gh-cli-secret\n"
|
||||||
|
|
||||||
|
def _fake_run(cmd, capture_output, text, timeout):
|
||||||
|
calls.append(cmd)
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.subprocess.run", _fake_run)
|
||||||
|
|
||||||
|
assert _try_gh_cli_token() == "gh-cli-secret"
|
||||||
|
assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]]
|
||||||
|
|
||||||
|
def test_resolve_copilot_acp_with_local_cli(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||||
|
|
||||||
|
creds = resolve_external_process_provider_credentials("copilot-acp")
|
||||||
|
|
||||||
|
assert creds["provider"] == "copilot-acp"
|
||||||
|
assert creds["api_key"] == "copilot-acp"
|
||||||
|
assert creds["base_url"] == "acp://copilot"
|
||||||
|
assert creds["command"] == "/usr/local/bin/copilot"
|
||||||
|
assert creds["args"] == ["--acp", "--stdio"]
|
||||||
|
assert creds["source"] == "process"
|
||||||
|
|
||||||
def test_resolve_kimi_with_key(self, monkeypatch):
|
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.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||||
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
result = resolve_runtime_provider(requested="copilot")
|
||||||
|
assert result["provider"] == "copilot"
|
||||||
|
assert result["api_mode"] == "chat_completions"
|
||||||
|
assert result["api_key"] == "gh-cli-secret"
|
||||||
|
assert result["base_url"] == "https://api.githubcopilot.com"
|
||||||
|
|
||||||
|
def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.runtime_provider._get_model_config",
|
||||||
|
lambda: {"provider": "copilot", "default": "gpt-5.4"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.models.fetch_github_model_catalog",
|
||||||
|
lambda api_key=None, timeout=5.0: [
|
||||||
|
{
|
||||||
|
"id": "gpt-5.4",
|
||||||
|
"supported_endpoints": ["/responses"],
|
||||||
|
"capabilities": {"type": "chat"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
|
||||||
|
result = resolve_runtime_provider(requested="copilot")
|
||||||
|
|
||||||
|
assert result["provider"] == "copilot"
|
||||||
|
assert result["api_mode"] == "codex_responses"
|
||||||
|
|
||||||
|
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||||
|
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
||||||
|
|
||||||
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
|
||||||
|
result = resolve_runtime_provider(requested="copilot-acp")
|
||||||
|
|
||||||
|
assert result["provider"] == "copilot-acp"
|
||||||
|
assert result["api_mode"] == "chat_completions"
|
||||||
|
assert result["api_key"] == "copilot-acp"
|
||||||
|
assert result["base_url"] == "acp://copilot"
|
||||||
|
assert result["command"] == "/usr/local/bin/copilot"
|
||||||
|
assert result["args"] == ["--acp", "--stdio", "--debug"]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# _has_any_provider_configured tests
|
# _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.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||||
|
hermes_home = tmp_path / ".hermes"
|
||||||
|
hermes_home.mkdir()
|
||||||
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||||
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||||
|
from hermes_cli.main import _has_any_provider_configured
|
||||||
|
assert _has_any_provider_configured() is True
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Kimi Code auto-detection tests
|
# 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"}]
|
||||||
|
|
@ -2172,6 +2194,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}
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,14 @@ class TestSearchLoopDetection(unittest.TestCase):
|
||||||
self.assertNotIn("_warning", result)
|
self.assertNotIn("_warning", result)
|
||||||
self.assertNotIn("error", result)
|
self.assertNotIn("error", result)
|
||||||
|
|
||||||
|
@patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops())
|
||||||
|
def test_pagination_offset_does_not_count_as_repeat(self, _mock_ops):
|
||||||
|
"""Paginating truncated results should not be blocked as a repeat search."""
|
||||||
|
for offset in (0, 50, 100, 150):
|
||||||
|
result = json.loads(search_tool("def main", task_id="t1", offset=offset, limit=50))
|
||||||
|
self.assertNotIn("_warning", result)
|
||||||
|
self.assertNotIn("error", result)
|
||||||
|
|
||||||
@patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops())
|
@patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops())
|
||||||
def test_read_between_searches_resets_consecutive(self, _mock_ops):
|
def test_read_between_searches_resets_consecutive(self, _mock_ops):
|
||||||
"""A read_file call between searches resets search consecutive counter."""
|
"""A read_file call between searches resets search consecutive counter."""
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,34 @@ class TestShouldAllowInstall:
|
||||||
assert allowed is True
|
assert allowed is True
|
||||||
assert "Force-installed" in reason
|
assert "Force-installed" in reason
|
||||||
|
|
||||||
|
# -- agent-created policy --
|
||||||
|
|
||||||
|
def test_safe_agent_created_allowed(self):
|
||||||
|
allowed, _ = should_allow_install(self._result("agent-created", "safe"))
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
def test_caution_agent_created_allowed(self):
|
||||||
|
"""Agent-created skills with caution verdict (e.g. docker refs) should pass."""
|
||||||
|
f = [Finding("docker_pull", "medium", "supply_chain", "SKILL.md", 1, "docker pull img", "pulls Docker image")]
|
||||||
|
allowed, reason = should_allow_install(self._result("agent-created", "caution", f))
|
||||||
|
assert allowed is True
|
||||||
|
assert "agent-created" in reason
|
||||||
|
|
||||||
|
def test_dangerous_agent_created_blocked(self):
|
||||||
|
"""Agent-created skills with dangerous verdict (critical findings) stay blocked."""
|
||||||
|
f = [Finding("env_exfil_curl", "critical", "exfiltration", "SKILL.md", 1, "curl $TOKEN", "exfiltration")]
|
||||||
|
allowed, reason = should_allow_install(self._result("agent-created", "dangerous", f))
|
||||||
|
assert allowed is False
|
||||||
|
assert "Blocked" in reason
|
||||||
|
|
||||||
|
def test_force_overrides_dangerous_for_agent_created(self):
|
||||||
|
f = [Finding("x", "critical", "c", "f", 1, "m", "d")]
|
||||||
|
allowed, reason = should_allow_install(
|
||||||
|
self._result("agent-created", "dangerous", f), force=True
|
||||||
|
)
|
||||||
|
assert allowed is True
|
||||||
|
assert "Force-installed" in reason
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# scan_file — pattern detection
|
# scan_file — pattern detection
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,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,
|
||||||
|
|
@ -212,6 +214,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),
|
||||||
|
|
@ -232,6 +236,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
|
||||||
|
|
@ -372,7 +377,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'):
|
||||||
|
|
@ -623,6 +632,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 []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,17 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||||
"""Search for content or files."""
|
"""Search for content or files."""
|
||||||
try:
|
try:
|
||||||
# Track searches to detect *consecutive* repeated search loops.
|
# Track searches to detect *consecutive* repeated search loops.
|
||||||
search_key = ("search", pattern, target, str(path), file_glob or "")
|
# Include pagination args so users can page through truncated
|
||||||
|
# results without tripping the repeated-search guard.
|
||||||
|
search_key = (
|
||||||
|
"search",
|
||||||
|
pattern,
|
||||||
|
target,
|
||||||
|
str(path),
|
||||||
|
file_glob or "",
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
with _read_tracker_lock:
|
with _read_tracker_lock:
|
||||||
task_data = _read_tracker.setdefault(task_id, {
|
task_data = _read_tracker.setdefault(task_id, {
|
||||||
"last_key": None, "consecutive": 0, "read_history": set(),
|
"last_key": None, "consecutive": 0, "read_history": set(),
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ INSTALL_POLICY = {
|
||||||
"builtin": ("allow", "allow", "allow"),
|
"builtin": ("allow", "allow", "allow"),
|
||||||
"trusted": ("allow", "allow", "block"),
|
"trusted": ("allow", "allow", "block"),
|
||||||
"community": ("allow", "block", "block"),
|
"community": ("allow", "block", "block"),
|
||||||
"agent-created": ("allow", "block", "block"),
|
"agent-created": ("allow", "allow", "block"),
|
||||||
}
|
}
|
||||||
|
|
||||||
VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2}
|
VERDICT_INDEX = {"safe": 0, "caution": 1, "dangerous": 2}
|
||||||
|
|
|
||||||
|
|
@ -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`, `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`. |
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,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 ACP** | `hermes model` (spawns local `copilot --acp --stdio`) |
|
||||||
|
| **GitHub Copilot** | `hermes model` (uses `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`) |
|
||||||
| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) |
|
| **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`) |
|
||||||
|
|
@ -122,6 +124,15 @@ model:
|
||||||
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# GitHub Copilot ACP agent backend
|
||||||
|
hermes chat --provider copilot-acp --model copilot-acp
|
||||||
|
# Requires the GitHub Copilot CLI in PATH and an existing `copilot login`
|
||||||
|
# session. Hermes starts `copilot --acp --stdio` for each request.
|
||||||
|
|
||||||
|
# GitHub Copilot
|
||||||
|
hermes chat --provider copilot --model gpt-5.4
|
||||||
|
# Uses: GITHUB_TOKEN, GH_TOKEN, or `gh auth token`
|
||||||
|
|
||||||
# z.ai / ZhipuAI GLM
|
# z.ai / ZhipuAI GLM
|
||||||
hermes chat --provider zai --model glm-4-plus
|
hermes chat --provider zai --model glm-4-plus
|
||||||
# Requires: GLM_API_KEY in ~/.hermes/.env
|
# Requires: GLM_API_KEY in ~/.hermes/.env
|
||||||
|
|
@ -146,11 +157,19 @@ hermes chat --provider alibaba --model qwen-plus
|
||||||
Or set the provider permanently in `config.yaml`:
|
Or set the provider permanently in `config.yaml`:
|
||||||
```yaml
|
```yaml
|
||||||
model:
|
model:
|
||||||
provider: "zai" # or: kimi-coding, minimax, minimax-cn, alibaba
|
provider: "copilot-acp" # or: copilot, zai, kimi-coding, minimax, minimax-cn, alibaba
|
||||||
default: "glm-4-plus"
|
default: "copilot-acp"
|
||||||
```
|
```
|
||||||
|
|
||||||
Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, or `DASHSCOPE_BASE_URL` environment variables.
|
Or, for the direct Copilot premium API provider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
provider: "copilot"
|
||||||
|
default: "gpt-5.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Base URLs can be overridden with `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, or `DASHSCOPE_BASE_URL` environment variables. The Copilot premium API provider uses the built-in GitHub Copilot API base URL automatically. The Copilot ACP backend can be pointed at a different executable with `HERMES_COPILOT_ACP_COMMAND`, `COPILOT_CLI_PATH`, and `HERMES_COPILOT_ACP_ARGS`.
|
||||||
|
|
||||||
## Custom & Self-Hosted LLM Providers
|
## Custom & Self-Hosted LLM Providers
|
||||||
|
|
||||||
|
|
@ -443,7 +462,7 @@ fallback_model:
|
||||||
|
|
||||||
When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session.
|
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 +785,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t
|
||||||
|
|
||||||
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
|
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
|
||||||
|
|
||||||
|
|
@ -1224,7 +1243,7 @@ delegation:
|
||||||
|
|
||||||
**Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only.
|
**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