Merge main into bb/gui.

Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
Brooklyn Nicholson
2026-05-15 15:33:28 -05:00
415 changed files with 38391 additions and 20402 deletions
+864 -2
View File
@@ -35,7 +35,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
@@ -72,6 +72,7 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
DEFAULT_XAI_OAUTH_BASE_URL = "https://api.x.ai/v1"
MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
@@ -89,6 +90,14 @@ STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
XAI_OAUTH_REDIRECT_PORT = 56121
XAI_OAUTH_REDIRECT_PATH = "/callback"
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@@ -162,6 +171,12 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
auth_type="oauth_external",
inference_base_url=DEFAULT_CODEX_BASE_URL,
),
"xai-oauth": ProviderConfig(
id="xai-oauth",
name="xAI Grok OAuth (SuperGrok Subscription)",
auth_type="oauth_external",
inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
),
"qwen-oauth": ProviderConfig(
id="qwen-oauth",
name="Qwen OAuth",
@@ -1364,6 +1379,8 @@ def resolve_provider(
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
"xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth",
"grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth",
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"step": "stepfun", "stepfun-coding-plan": "stepfun",
@@ -1907,6 +1924,16 @@ def _spotify_code_challenge(code_verifier: str) -> str:
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _oauth_pkce_code_verifier(length: int = 64) -> str:
raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii")
return raw.rstrip("=")[:128]
def _oauth_pkce_code_challenge(code_verifier: str) -> str:
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _spotify_build_authorize_url(
*,
client_id: str,
@@ -2029,6 +2056,158 @@ def _spotify_wait_for_callback(
)
def _xai_validate_loopback_redirect_uri(redirect_uri: str) -> tuple[str, int, str]:
parsed = urlparse(redirect_uri)
if parsed.scheme != "http":
raise AuthError(
"xAI OAuth redirect_uri must use http://127.0.0.1.",
provider="xai-oauth",
code="xai_redirect_invalid",
)
host = parsed.hostname or ""
if host != XAI_OAUTH_REDIRECT_HOST:
raise AuthError(
"xAI OAuth redirect_uri must point to 127.0.0.1.",
provider="xai-oauth",
code="xai_redirect_invalid",
)
if not parsed.port:
raise AuthError(
"xAI OAuth redirect_uri must include an explicit localhost port.",
provider="xai-oauth",
code="xai_redirect_invalid",
)
return host, parsed.port, parsed.path or "/"
def _xai_callback_cors_origin(origin: Optional[str]) -> str:
# CORS allowlist for the loopback callback. Only xAI's own auth origins
# are accepted; the redirect_uri itself is bound to 127.0.0.1 and gated by
# PKCE+state, so additional dev/3p origins are not needed here.
allowed = {
"https://accounts.x.ai",
"https://auth.x.ai",
}
return origin if origin in allowed else ""
def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:
result: dict[str, Any] = {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
class _XAICallbackHandler(BaseHTTPRequestHandler):
def _maybe_write_cors_headers(self) -> None:
origin = self.headers.get("Origin")
allow_origin = _xai_callback_cors_origin(origin)
if allow_origin:
self.send_header("Access-Control-Allow-Origin", allow_origin)
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Private-Network", "true")
self.send_header("Vary", "Origin")
def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(204)
self._maybe_write_cors_headers()
self.end_headers()
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != expected_path:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found.")
return
params = parse_qs(parsed.query)
result["code"] = params.get("code", [None])[0]
result["state"] = params.get("state", [None])[0]
result["error"] = params.get("error", [None])[0]
result["error_description"] = params.get("error_description", [None])[0]
self.send_response(200)
self._maybe_write_cors_headers()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
if result["error"]:
body = "<html><body><h1>xAI authorization failed.</h1>You can close this tab.</body></html>"
else:
body = "<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>"
self.wfile.write(body.encode("utf-8"))
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
return _XAICallbackHandler, result
def _xai_start_callback_server(
preferred_port: int = XAI_OAUTH_REDIRECT_PORT,
) -> tuple[HTTPServer, threading.Thread, dict[str, Any], str]:
host = XAI_OAUTH_REDIRECT_HOST
expected_path = XAI_OAUTH_REDIRECT_PATH
handler_cls, result = _make_xai_callback_handler(expected_path)
class _ReuseHTTPServer(HTTPServer):
allow_reuse_address = True
ports_to_try = [preferred_port]
if preferred_port != 0:
ports_to_try.append(0)
server = None
last_error: Optional[OSError] = None
for port in ports_to_try:
try:
server = _ReuseHTTPServer((host, port), handler_cls)
break
except OSError as exc:
last_error = exc
if server is None:
raise AuthError(
f"Could not bind xAI callback server on {host}:{preferred_port}: {last_error}",
provider="xai-oauth",
code="xai_callback_bind_failed",
) from last_error
actual_port = int(server.server_address[1])
redirect_uri = f"http://{host}:{actual_port}{expected_path}"
thread = threading.Thread(
target=server.serve_forever,
kwargs={"poll_interval": 0.1},
daemon=True,
)
thread.start()
return server, thread, result, redirect_uri
def _xai_wait_for_callback(
server: HTTPServer,
thread: threading.Thread,
result: dict[str, Any],
*,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
deadline = time.monotonic() + max(5.0, timeout_seconds)
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
time.sleep(0.1)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
raise AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
@@ -2680,6 +2859,348 @@ def resolve_codex_runtime_credentials(
}
# =============================================================================
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
# =============================================================================
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
if _lock:
with _auth_store_lock():
auth_store = _load_auth_store()
else:
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "xai-oauth")
if not state:
raise AuthError(
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok Subscription) in `hermes model`.",
provider="xai-oauth",
code="xai_auth_missing",
relogin_required=True,
)
tokens = state.get("tokens")
if not isinstance(tokens, dict):
raise AuthError(
"xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.",
provider="xai-oauth",
code="xai_auth_invalid_shape",
relogin_required=True,
)
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if not access_token:
raise AuthError(
"xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.",
provider="xai-oauth",
code="xai_auth_missing_access_token",
relogin_required=True,
)
if not refresh_token:
raise AuthError(
"xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.",
provider="xai-oauth",
code="xai_auth_missing_refresh_token",
relogin_required=True,
)
return {
"tokens": tokens,
"last_refresh": state.get("last_refresh"),
"discovery": state.get("discovery") or {},
"redirect_uri": state.get("redirect_uri"),
}
def _save_xai_oauth_tokens(
tokens: Dict[str, Any],
*,
discovery: Optional[Dict[str, Any]] = None,
redirect_uri: str = "",
last_refresh: Optional[str] = None,
) -> None:
if last_refresh is None:
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "xai-oauth") or {}
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "oauth_pkce"
if discovery:
state["discovery"] = discovery
if redirect_uri:
state["redirect_uri"] = redirect_uri
_save_provider_state(auth_store, "xai-oauth", state)
_save_auth_store(auth_store)
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
if not isinstance(access_token, str) or "." not in access_token:
return False
try:
parts = access_token.split(".")
if len(parts) < 2:
return False
payload_b64 = parts[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8"))
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return False
return float(exp) <= (time.time() + max(0, int(skew_seconds)))
except Exception:
return False
def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str:
"""Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin.
The OIDC discovery response is a long-lived, low-frequency request whose
output is cached in ``~/.hermes/auth.json``. A single MITM during initial
login could substitute a malicious ``token_endpoint``; that URL would
then receive the refresh_token on every subsequent refresh — a permanent
credential leak from a one-time MITM. Validating scheme + host pins the
cached endpoint to the xAI auth origin (or a future ``*.x.ai`` subdomain
if xAI migrates) so the cache poisoning loses its persistence guarantee.
RFC 8414 §2 requires the issuer to be ``https://`` and SHOULD-keeps the
token_endpoint on the same origin; we enforce both. ``x.ai`` is the
bare apex, so we accept either exact host match or any ``.x.ai`` suffix.
"""
parsed = urlparse(url)
if parsed.scheme != "https":
raise AuthError(
f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.",
provider="xai-oauth",
code="xai_discovery_invalid",
)
host = (parsed.hostname or "").lower()
if not host:
raise AuthError(
f"xAI OIDC discovery {field} is missing a hostname: {url!r}.",
provider="xai-oauth",
code="xai_discovery_invalid",
)
if host != "x.ai" and not host.endswith(".x.ai"):
raise AuthError(
f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin "
f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached "
f"endpoint that may have been substituted by a MITM during initial "
f"discovery; re-authenticate with `hermes model` to re-fetch.",
provider="xai-oauth",
code="xai_discovery_invalid",
)
return url
def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]:
try:
response = httpx.get(
XAI_OAUTH_DISCOVERY_URL,
headers={"Accept": "application/json"},
timeout=timeout_seconds,
)
except Exception as exc:
raise AuthError(
f"xAI OIDC discovery failed: {exc}",
provider="xai-oauth",
code="xai_discovery_failed",
) from exc
if response.status_code != 200:
raise AuthError(
f"xAI OIDC discovery returned status {response.status_code}.",
provider="xai-oauth",
code="xai_discovery_failed",
)
try:
payload = response.json()
except Exception as exc:
raise AuthError(
f"xAI OIDC discovery returned invalid JSON: {exc}",
provider="xai-oauth",
code="xai_discovery_invalid_json",
) from exc
if not isinstance(payload, dict):
raise AuthError(
"xAI OIDC discovery response was not a JSON object.",
provider="xai-oauth",
code="xai_discovery_incomplete",
)
authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip()
token_endpoint = str(payload.get("token_endpoint", "") or "").strip()
if not authorization_endpoint or not token_endpoint:
raise AuthError(
"xAI OIDC discovery response was missing required endpoints.",
provider="xai-oauth",
code="xai_discovery_incomplete",
)
_xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint")
_xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint")
return {
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
}
def refresh_xai_oauth_pure(
access_token: str,
refresh_token: str,
*,
token_endpoint: str = "",
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
del access_token
if not isinstance(refresh_token, str) or not refresh_token.strip():
raise AuthError(
"xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.",
provider="xai-oauth",
code="xai_auth_missing_refresh_token",
relogin_required=True,
)
endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"]
# Re-validate cached endpoints on the refresh hot path: an auth.json
# written by an older Hermes (or hand-edited) may carry a non-xAI
# token_endpoint that would receive every future refresh_token in
# plaintext if we trusted it blindly. Cheap suffix check; fast-fail
# with a clear error so the user can re-run `hermes model` to refetch.
_xai_validate_oauth_endpoint(endpoint, field="token_endpoint")
timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
response = client.post(
endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "refresh_token",
"client_id": XAI_OAUTH_CLIENT_ID,
"refresh_token": refresh_token,
},
)
if response.status_code != 200:
detail = response.text.strip()
raise AuthError(
"xAI token refresh failed."
+ (f" Response: {detail}" if detail else ""),
provider="xai-oauth",
code="xai_refresh_failed",
relogin_required=(response.status_code in {400, 401, 403}),
)
try:
payload = response.json()
except Exception as exc:
raise AuthError(
f"xAI token refresh returned invalid JSON: {exc}",
provider="xai-oauth",
code="xai_refresh_invalid_json",
) from exc
if not isinstance(payload, dict):
raise AuthError(
"xAI token refresh response was not a JSON object.",
provider="xai-oauth",
code="xai_refresh_invalid_response",
relogin_required=True,
)
refreshed_access = str(payload.get("access_token", "") or "").strip()
if not refreshed_access:
raise AuthError(
"xAI token refresh response was missing access_token.",
provider="xai-oauth",
code="xai_refresh_missing_access_token",
relogin_required=True,
)
updated = {
"access_token": refreshed_access,
"refresh_token": str(payload.get("refresh_token") or refresh_token).strip(),
"id_token": str(payload.get("id_token") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
"last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
return updated
def _refresh_xai_oauth_tokens(
tokens: Dict[str, Any],
*,
token_endpoint: str,
redirect_uri: str = "",
timeout_seconds: float,
) -> Dict[str, Any]:
refreshed = refresh_xai_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
token_endpoint=token_endpoint,
timeout_seconds=timeout_seconds,
)
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
if refreshed.get("id_token"):
updated_tokens["id_token"] = refreshed["id_token"]
if refreshed.get("expires_in") is not None:
updated_tokens["expires_in"] = refreshed["expires_in"]
if refreshed.get("token_type"):
updated_tokens["token_type"] = refreshed["token_type"]
_save_xai_oauth_tokens(
updated_tokens,
discovery={"token_endpoint": token_endpoint},
redirect_uri=redirect_uri,
last_refresh=refreshed["last_refresh"],
)
return updated_tokens
def resolve_xai_oauth_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
refresh_skew_seconds: int = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
data = _read_xai_oauth_tokens()
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_timeout_seconds = float(os.getenv("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", "20"))
discovery = dict(data.get("discovery") or {})
token_endpoint = str(discovery.get("token_endpoint", "") or "").strip()
redirect_uri = str(data.get("redirect_uri", "") or "").strip()
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds)
if should_refresh:
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
data = _read_xai_oauth_tokens(_lock=False)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
discovery = dict(data.get("discovery") or {})
token_endpoint = str(discovery.get("token_endpoint", "") or "").strip()
redirect_uri = str(data.get("redirect_uri", "") or "").strip()
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
should_refresh = _xai_access_token_is_expiring(access_token, refresh_skew_seconds)
if should_refresh:
if not token_endpoint:
token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"]
tokens = _refresh_xai_oauth_tokens(
tokens,
token_endpoint=token_endpoint,
redirect_uri=redirect_uri,
timeout_seconds=refresh_timeout_seconds,
)
access_token = str(tokens.get("access_token", "") or "").strip()
base_url = (
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
or DEFAULT_XAI_OAUTH_BASE_URL
)
return {
"provider": "xai-oauth",
"base_url": base_url,
"api_key": access_token,
"source": "hermes-auth-store",
"last_refresh": data.get("last_refresh"),
"auth_mode": "oauth_pkce",
}
# =============================================================================
# TLS verification helper
# =============================================================================
@@ -3870,6 +4391,39 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
return _empty_nous_auth_status()
# ── Process-level memo for get_nous_auth_status() ──
# get_nous_auth_status() validates state by calling resolve_nous_runtime_credentials(),
# which does a synchronous OAuth refresh POST to portal.nousresearch.com. That can take
# ~350ms even on the failure path, and read-only UI surfaces (`hermes tools`, status panels,
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
def _auth_file_mtime() -> Optional[float]:
try:
return _auth_file_path().stat().st_mtime
except FileNotFoundError:
return None
except Exception:
return None
def invalidate_nous_auth_status_cache() -> None:
"""Clear the get_nous_auth_status() process-level memo.
Call this from any code path that mutates Nous auth state without going
through resolve_nous_runtime_credentials() (e.g. tests). Login/logout
flows touch auth.json, so the mtime check below invalidates them
automatically — explicit invalidation is the belt-and-braces option.
"""
global _nous_auth_status_cache
_nous_auth_status_cache = None
def get_nous_auth_status() -> Dict[str, Any]:
"""Status snapshot for Nous auth.
@@ -3878,7 +4432,32 @@ def get_nous_auth_status() -> Dict[str, Any]:
by resolving runtime credentials so revoked refresh sessions do not show up
as a healthy login. If provider state is absent, fall back to the credential
pool for the just-logged-in / not-yet-promoted case.
The returned snapshot is memoised for ~15s keyed on the auth.json mtime,
so menu/status surfaces that ask repeatedly don't trigger one refresh POST
per call. Login/logout flows write to auth.json and therefore invalidate
the cache automatically; tests can also call
``invalidate_nous_auth_status_cache()`` explicitly.
"""
global _nous_auth_status_cache
now = time.monotonic()
mtime = _auth_file_mtime()
cached = _nous_auth_status_cache
if cached is not None:
cached_at, cached_mtime, cached_status = cached
if (
cached_mtime == mtime
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
):
return dict(cached_status)
status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, mtime, dict(status))
return status
def _compute_nous_auth_status() -> Dict[str, Any]:
"""Uncached implementation of get_nous_auth_status(). See that function."""
state = get_provider_auth_state("nous")
if state:
base_status = {
@@ -3972,6 +4551,48 @@ def get_codex_auth_status() -> Dict[str, Any]:
}
def get_xai_oauth_auth_status() -> Dict[str, Any]:
try:
from agent.credential_pool import load_pool
pool = load_pool("xai-oauth")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
api_key = (
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
)
if api_key and not _xai_access_token_is_expiring(api_key, 0):
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": "oauth_pkce",
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
except Exception:
pass
try:
creds = resolve_xai_oauth_runtime_credentials()
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
}
except AuthError as exc:
return {
"logged_in": False,
"auth_store": str(_auth_file_path()),
"error": str(exc),
}
def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
"""Status snapshot for API-key providers (z.ai, Kimi, MiniMax)."""
pconfig = PROVIDER_REGISTRY.get(provider_id)
@@ -4042,6 +4663,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_nous_auth_status()
if target == "openai-codex":
return get_codex_auth_status()
if target == "xai-oauth":
return get_xai_oauth_auth_status()
if target == "qwen-oauth":
return get_qwen_auth_status()
if target == "google-gemini-cli":
@@ -4262,7 +4885,7 @@ def _logout_default_provider_from_config() -> Optional[str]:
"No provider is currently logged in" and never reset model.provider.
"""
provider = _get_config_provider()
if provider in {"nous", "openai-codex"}:
if provider in {"nous", "openai-codex", "xai-oauth"}:
return provider
return None
@@ -4561,6 +5184,245 @@ def _login_openai_codex(
print(f" Config updated: {config_path} (model.provider=openai-codex)")
def _login_xai_oauth(
args,
pconfig: ProviderConfig,
*,
force_new_login: bool = False,
) -> None:
del pconfig
if not force_new_login:
try:
existing = resolve_xai_oauth_runtime_credentials()
api_key = existing.get("api_key", "")
if isinstance(api_key, str) and api_key and not _xai_access_token_is_expiring(api_key, 60):
print("Existing xAI OAuth credentials found in Hermes auth store.")
try:
reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
reuse = "y"
if reuse in ("", "y", "yes"):
config_path = _update_config_for_provider(
"xai-oauth",
existing.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL),
)
print()
print("Login successful!")
print(f" Config updated: {config_path} (model.provider=xai-oauth)")
return
except AuthError:
pass
print()
print("Signing in to xAI Grok OAuth (SuperGrok Subscription)...")
print("(Hermes creates its own local OAuth session)")
print()
timeout_seconds = float(getattr(args, "timeout", None) or 20.0)
open_browser = not getattr(args, "no_browser", False)
if _is_remote_session():
open_browser = False
creds = _xai_oauth_loopback_login(timeout_seconds=timeout_seconds, open_browser=open_browser)
_save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
)
config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL))
print()
print("Login successful!")
from hermes_constants import display_hermes_home as _dhh
print(f" Auth state: {_dhh()}/auth.json")
print(f" Config updated: {config_path} (model.provider=xai-oauth)")
def _xai_oauth_build_authorize_url(
*,
authorization_endpoint: str,
redirect_uri: str,
code_challenge: str,
state: str,
nonce: str,
) -> str:
# `plan=generic` opts the consent screen into xAI's generic OAuth plan
# tier instead of falling back to the per-account default. Without it,
# accounts.x.ai rejects loopback OAuth from non-allowlisted clients.
# `referrer=hermes-agent` lets xAI attribute Hermes-originated logins
# in their OAuth server logs (we still impersonate the upstream Grok-CLI
# client_id; this is best-effort attribution until xAI mints us our own).
authorize_params = {
"response_type": "code",
"client_id": XAI_OAUTH_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": XAI_OAUTH_SCOPE,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": state,
"nonce": nonce,
"plan": "generic",
"referrer": "hermes-agent",
}
return f"{authorization_endpoint}?{urlencode(authorize_params)}"
def _xai_oauth_loopback_login(
*,
timeout_seconds: float = 20.0,
open_browser: bool = True,
) -> Dict[str, Any]:
discovery = _xai_oauth_discovery(timeout_seconds)
authorization_endpoint = discovery["authorization_endpoint"]
token_endpoint = discovery["token_endpoint"]
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
try:
_xai_validate_loopback_redirect_uri(redirect_uri)
code_verifier = _oauth_pkce_code_verifier()
code_challenge = _oauth_pkce_code_challenge(code_verifier)
state = uuid.uuid4().hex
nonce = uuid.uuid4().hex
authorize_url = _xai_oauth_build_authorize_url(
authorization_endpoint=authorization_endpoint,
redirect_uri=redirect_uri,
code_challenge=code_challenge,
state=state,
nonce=nonce,
)
print("Open this URL to authorize Hermes with xAI:")
print(authorize_url)
print()
print(f"Waiting for callback on {redirect_uri}")
if open_browser and not _is_remote_session():
try:
opened = webbrowser.open(authorize_url)
except Exception:
opened = False
if opened:
print("Browser opened for xAI authorization.")
else:
print("Could not open the browser automatically; use the URL above.")
callback = _xai_wait_for_callback(
server,
thread,
callback_result,
timeout_seconds=max(30.0, timeout_seconds * 9),
)
except Exception:
try:
server.shutdown()
server.server_close()
except Exception:
pass
try:
thread.join(timeout=1.0)
except Exception:
pass
raise
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
raise AuthError(
f"xAI authorization failed: {detail}",
provider="xai-oauth",
code="xai_authorization_failed",
)
if callback.get("state") != state:
raise AuthError(
"xAI authorization failed: state mismatch.",
provider="xai-oauth",
code="xai_state_mismatch",
)
code = str(callback.get("code") or "").strip()
if not code:
raise AuthError(
"xAI authorization failed: missing authorization code.",
provider="xai-oauth",
code="xai_code_missing",
)
try:
response = httpx.post(
token_endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": XAI_OAUTH_CLIENT_ID,
"code_verifier": code_verifier,
},
timeout=max(20.0, timeout_seconds),
)
except Exception as exc:
raise AuthError(
f"xAI token exchange failed: {exc}",
provider="xai-oauth",
code="xai_token_exchange_failed",
) from exc
if response.status_code != 200:
detail = response.text.strip()
raise AuthError(
"xAI token exchange failed."
+ (f" Response: {detail}" if detail else ""),
provider="xai-oauth",
code="xai_token_exchange_failed",
)
try:
payload = response.json()
except Exception as exc:
raise AuthError(
f"xAI token exchange returned invalid JSON: {exc}",
provider="xai-oauth",
code="xai_token_exchange_invalid",
) from exc
if not isinstance(payload, dict):
raise AuthError(
"xAI token exchange response was not a JSON object.",
provider="xai-oauth",
code="xai_token_exchange_invalid",
)
access_token = str(payload.get("access_token", "") or "").strip()
refresh_token = str(payload.get("refresh_token", "") or "").strip()
if not access_token:
raise AuthError(
"xAI token exchange did not return an access_token.",
provider="xai-oauth",
code="xai_token_exchange_invalid",
)
if not refresh_token:
raise AuthError(
"xAI token exchange did not return a refresh_token.",
provider="xai-oauth",
code="xai_token_exchange_invalid",
)
base_url = (
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
or DEFAULT_XAI_OAUTH_BASE_URL
)
return {
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": str(payload.get("id_token", "") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
},
"discovery": discovery,
"redirect_uri": redirect_uri,
"base_url": base_url,
"last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"source": "oauth-loopback",
}
def _codex_device_code_login() -> Dict[str, Any]:
"""Run the OpenAI device code login flow and return credentials dict."""
import time as _time
+29 -2
View File
@@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
# Providers that support OAuth login in addition to API keys.
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
def _get_custom_provider_names() -> list:
@@ -77,6 +77,8 @@ def _normalize_provider(provider: str) -> str:
normalized = (provider or "").strip().lower()
if normalized in {"or", "open-router"}:
return "openrouter"
if normalized in {"grok-oauth", "xai-oauth", "x-ai-oauth", "xai-grok-oauth"}:
return "xai-oauth"
# Check if it matches a custom provider name
custom_key = _resolve_custom_provider_input(normalized)
if custom_key:
@@ -170,7 +172,7 @@ def auth_add_command(args) -> None:
if provider.startswith(CUSTOM_POOL_PREFIX):
requested_type = AUTH_TYPE_API_KEY
else:
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY
requested_type = AUTH_TYPE_OAUTH if provider in _OAUTH_CAPABLE_PROVIDERS else AUTH_TYPE_API_KEY
pool = load_pool(provider)
@@ -333,6 +335,31 @@ def auth_add_command(args) -> None:
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "xai-oauth":
creds = auth_mod._xai_oauth_loopback_login(
timeout_seconds=getattr(args, "timeout", None) or 20.0,
open_browser=not getattr(args, "no_browser", False),
)
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds["tokens"]["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:xai_pkce",
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
last_refresh=creds.get("last_refresh"),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
return
if provider == "google-gemini-cli":
from agent.google_oauth import run_gemini_oauth_login_pure
+16
View File
@@ -470,6 +470,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
model_short = model_short[:25] + "..."
ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]")
if os.getenv("HERMES_YOLO_MODE"):
left_lines.append(f"[bold red]⚠ YOLO mode[/] [dim {dim}]— all approval prompts bypassed[/]")
left_lines.append(f"[dim {dim}]{cwd}[/]")
if session_id:
left_lines.append(f"[dim {session_color}]Session: {session_id}[/]")
@@ -581,6 +584,19 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
if mcp_connected:
summary_parts.append(f"{mcp_connected} MCP servers")
summary_parts.append("/help for commands")
# Indicate when the codex_app_server runtime is active so users
# understand why tool counts may not match what's actually reachable
# (codex builds its own tool list inside the spawned subprocess).
try:
from hermes_cli.codex_runtime_switch import get_current_runtime
from hermes_cli.config import load_config as _load_cfg
if get_current_runtime(_load_cfg()) == "codex_app_server":
right_lines.append(
f"[bold {accent}]Runtime:[/] [{text}]codex app-server[/] "
f"[dim {dim}](terminal/file ops/MCP run inside codex)[/]"
)
except Exception:
pass
# Show active profile name when not 'default'
try:
from hermes_cli.profiles import get_active_profile_name
+17 -4
View File
@@ -22,6 +22,7 @@ from pathlib import Path
from hermes_constants import is_wsl as _is_wsl
logger = logging.getLogger(__name__)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def save_clipboard_image(dest: Path) -> bool:
@@ -378,10 +379,13 @@ def _wayland_save(dest: Path) -> bool:
dest.unlink(missing_ok=True)
return False
# BMP needs conversion to PNG (common in WSLg where only BMP
# is bridged from Windows clipboard via RDP).
if mime == "image/bmp":
return _convert_to_png(dest)
# save_clipboard_image() promises a PNG output path. Wayland can offer
# JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before
# returning success.
if mime != "image/png":
if not _convert_to_png(dest) or not _is_png_file(dest):
dest.unlink(missing_ok=True)
return False
return True
@@ -433,6 +437,15 @@ def _convert_to_png(path: Path) -> bool:
return path.exists() and path.stat().st_size > 0
def _is_png_file(path: Path) -> bool:
"""Return True when *path* starts with the PNG file signature."""
try:
with path.open("rb") as f:
return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE
except OSError:
return False
# ── X11 (xclip) ─────────────────────────────────────────────────────────
def _xclip_has_image() -> bool:
@@ -0,0 +1,757 @@
"""Migrate Hermes' MCP server config and Codex's installed curated plugins
to the format Codex expects in ~/.codex/config.toml.
When the user enables the codex_app_server runtime, the codex subprocess
runs its own MCP client and its own plugin runtime (Linear, Atlassian,
Asana, plus per-account ChatGPT apps via app/list). For both of those to
be useful, the user's choices need to be visible to codex too. This
module:
1. Reads Hermes' YAML and writes equivalent [mcp_servers.<name>]
entries to ~/.codex/config.toml.
2. Queries codex's `plugin/list` for the openai-curated marketplace
and writes [plugins."<name>@<marketplace>"] entries for any plugin
the user has installed=true on their codex CLI. (This is what
OpenClaw calls "migrate native codex plugins" — the YouTube-video-
worthy bit Pash highlighted: Canva, GitHub, Calendar, Gmail
pre-configured.)
3. Writes a [permissions] default profile so users on this runtime
don't get an approval prompt on every write attempt.
What translates (MCP servers):
Hermes mcp_servers.<n>.command/args/env → codex stdio transport
Hermes mcp_servers.<n>.url/headers → codex streamable_http transport
Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec
Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec
What does NOT translate (warned + skipped):
Hermes-specific keys (sampling, etc.) — codex's MCP client has no
equivalent. Listed in the per-server skipped[] field of the report.
What's NOT migrated (intentional):
AGENTS.md — codex respects this file natively in its cwd. Hermes' own
AGENTS.md (project-level) is already in the worktree, so codex picks
it up without translation. No code needed.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Marker comments wrapping the managed section so re-runs can detect
# what's ours and what's user-edited. Both must appear or strip is a no-op.
MIGRATION_MARKER = (
"# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section"
)
MIGRATION_END_MARKER = (
"# end hermes-agent managed section"
)
@dataclass
class MigrationReport:
"""Outcome of a migration pass."""
target_path: Optional[Path] = None
migrated: list[str] = field(default_factory=list)
skipped_keys_per_server: dict[str, list[str]] = field(default_factory=dict)
migrated_plugins: list[str] = field(default_factory=list)
plugin_query_error: Optional[str] = None
wrote_permissions_default: Optional[str] = None
errors: list[str] = field(default_factory=list)
written: bool = False
dry_run: bool = False
def summary(self) -> str:
lines = []
if self.dry_run:
lines.append(f"(dry run) Would write {self.target_path}")
elif self.written:
lines.append(f"Wrote {self.target_path}")
if self.migrated:
lines.append(f"Migrated {len(self.migrated)} MCP server(s):")
for name in self.migrated:
skipped = self.skipped_keys_per_server.get(name, [])
note = (
f" (skipped: {', '.join(skipped)})" if skipped else ""
)
lines.append(f" - {name}{note}")
else:
lines.append("No MCP servers found in Hermes config.")
if self.migrated_plugins:
lines.append(
f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):"
)
for name in self.migrated_plugins:
lines.append(f" - {name}")
elif self.plugin_query_error:
lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}")
if self.wrote_permissions_default:
lines.append(
f"Wrote default_permissions = "
f"{self.wrote_permissions_default!r}"
)
for err in self.errors:
lines.append(f"{err}")
return "\n".join(lines)
# Hermes keys that codex's MCP schema doesn't support — dropped during
# migration with a warning. Anything not on the keep list AND not the
# transport keys is added to skipped.
_KNOWN_HERMES_KEYS = {
# transport — stdio
"command", "args", "env", "cwd",
# transport — http
"url", "headers", "transport",
# timeouts
"timeout", "connect_timeout",
# general
"enabled", "description",
}
# Subset that have a direct codex equivalent.
_KEYS_DROPPED_WITH_WARNING = {
# Hermes' sampling subsection — codex MCP has no equivalent
"sampling",
}
def _translate_one_server(
name: str, hermes_cfg: dict
) -> tuple[Optional[dict], list[str]]:
"""Translate one Hermes MCP server config to the codex inline-table dict
representation. Returns (codex_entry, skipped_keys).
codex_entry is a dict ready for TOML serialization, or None when the
server can't be translated (e.g. neither command nor url present)."""
if not isinstance(hermes_cfg, dict):
return None, []
skipped: list[str] = []
out: dict[str, Any] = {}
has_command = bool(hermes_cfg.get("command"))
has_url = bool(hermes_cfg.get("url"))
if has_command and has_url:
skipped.append("url (both command and url set; preferring stdio)")
has_url = False
if has_command:
# Stdio transport
out["command"] = str(hermes_cfg["command"])
args = hermes_cfg.get("args") or []
if args:
out["args"] = [str(a) for a in args]
env = hermes_cfg.get("env") or {}
if env:
# Codex expects string values
out["env"] = {str(k): str(v) for k, v in env.items()}
cwd = hermes_cfg.get("cwd")
if cwd:
out["cwd"] = str(cwd)
elif has_url:
# streamable_http transport (codex covers both http and SSE here)
out["url"] = str(hermes_cfg["url"])
headers = hermes_cfg.get("headers") or {}
if headers:
out["http_headers"] = {str(k): str(v) for k, v in headers.items()}
# Hermes' transport: sse hint is informational; codex auto-negotiates
if hermes_cfg.get("transport") == "sse":
skipped.append("transport=sse (codex auto-negotiates)")
else:
return None, ["no command or url field"]
# Timeouts
if "timeout" in hermes_cfg:
try:
out["tool_timeout_sec"] = float(hermes_cfg["timeout"])
except (TypeError, ValueError):
skipped.append("timeout (not numeric)")
if "connect_timeout" in hermes_cfg:
try:
out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"])
except (TypeError, ValueError):
skipped.append("connect_timeout (not numeric)")
# Enabled flag (codex defaults to true so we only emit when explicitly false)
if hermes_cfg.get("enabled") is False:
out["enabled"] = False
# Detect keys we explicitly drop with warning
for key in hermes_cfg:
if key in _KEYS_DROPPED_WITH_WARNING:
skipped.append(f"{key} (no codex equivalent)")
elif key not in _KNOWN_HERMES_KEYS:
skipped.append(f"{key} (unknown Hermes key)")
return out, skipped
def _format_toml_value(value: Any) -> str:
"""Minimal TOML value formatter for the value types we emit.
We only emit strings, numbers, booleans, and tables of those — no nested
arrays of tables. This covers everything codex's MCP schema accepts."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, str):
# Escape per TOML basic-string rules. Order matters: backslash
# first so the other escapes don't get re-escaped.
# Control characters (newline, tab, etc.) must use \-escapes
# because TOML basic strings don't allow literal control chars
# — passing them through would produce invalid TOML that codex
# would refuse to load. Paths usually don't contain control
# chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could
# in pathological cases.
escaped = (
value
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\b", "\\b")
.replace("\t", "\\t")
.replace("\n", "\\n")
.replace("\f", "\\f")
.replace("\r", "\\r")
)
return f'"{escaped}"'
if isinstance(value, list):
items = ", ".join(_format_toml_value(v) for v in value)
return f"[{items}]"
if isinstance(value, dict):
items = ", ".join(
f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items()
)
return "{ " + items + " }" if items else "{}"
raise ValueError(f"Unsupported TOML value type: {type(value).__name__}")
def _quote_key(key: str) -> str:
"""Return key bare-or-quoted depending on whether it's a valid bare key."""
if all(c.isalnum() or c in "-_" for c in key) and key:
return key
escaped = key.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def render_codex_toml_section(
servers: dict[str, dict],
plugins: Optional[list[dict]] = None,
default_permission_profile: Optional[str] = None,
) -> str:
"""Render the managed [mcp_servers.<n>] / [plugins.<id>] / [permissions]
block for ~/.codex/config.toml.
Args:
servers: dict of MCP server name → translated codex inline-table
plugins: optional list of {name, marketplace, enabled} for native
Codex plugins to enable. (E.g. the Linear / Atlassian / Asana
curated plugins, or per-account ChatGPT apps.)
default_permission_profile: when set, write `[permissions] default`
so the user doesn't get an approval prompt on every write
attempt. Common values: "workspace-write", "read-only",
"full-access".
"""
out = [MIGRATION_MARKER]
if not servers and not plugins and not default_permission_profile:
out.append("# (no MCP servers, plugins, or permissions configured by Hermes)")
out.append(MIGRATION_END_MARKER)
return "\n".join(out) + "\n"
if default_permission_profile:
# Codex's config schema: `default_permissions` is a top-level
# string referencing a profile name. Built-in profile names start
# with ":" (":workspace-write", ":read-only", ":full-access"). The
# [permissions] table is for *user-defined* named profiles with
# structured fields — not what we want.
normalized = (
default_permission_profile
if default_permission_profile.startswith(":")
else f":{default_permission_profile}"
)
out.append("")
out.append(f"default_permissions = {_format_toml_value(normalized)}")
if servers:
for name in sorted(servers.keys()):
cfg = servers[name]
out.append("")
out.append(f"[mcp_servers.{_quote_key(name)}]")
for k, v in cfg.items():
out.append(f"{_quote_key(k)} = {_format_toml_value(v)}")
if plugins:
for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"):
name = plugin.get("name") or ""
marketplace = plugin.get("marketplace") or "openai-curated"
enabled = bool(plugin.get("enabled", True))
qualified = f"{name}@{marketplace}"
out.append("")
out.append(f'[plugins.{_quote_key(qualified)}]')
out.append(f"enabled = {_format_toml_value(enabled)}")
out.append("")
out.append(MIGRATION_END_MARKER)
return "\n".join(out) + "\n"
def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str:
"""Insert Hermes' managed Codex TOML block while keeping root keys root-scoped.
TOML has no syntax to return to the document root after a table header.
Therefore appending a root key like `default_permissions = ...` after a
user table such as `[features]` actually creates `features.default_permissions`,
which Codex rejects. Insert the managed block before the first table header
so its root keys remain top-level, while preserving user content verbatim.
"""
if not user_text.strip():
return managed_block
lines = user_text.splitlines(keepends=True)
first_table_idx: Optional[int] = None
for idx, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("["):
first_table_idx = idx
break
if first_table_idx is None:
prefix = user_text.rstrip("\n")
return f"{prefix}\n\n{managed_block}" if prefix else managed_block
prefix = "".join(lines[:first_table_idx]).rstrip("\n")
suffix = "".join(lines[first_table_idx:]).lstrip("\n")
if prefix:
return f"{prefix}\n\n{managed_block}\n{suffix}"
return f"{managed_block}\n{suffix}"
def _strip_unmanaged_plugin_tables(toml_text: str) -> str:
"""Remove ``[plugins."<name>@<marketplace>"]`` tables that live OUTSIDE the
managed block.
Codex itself writes these tables when the user runs ``codex plugins enable``
directly (i.e. before Hermes' migrate has ever touched the file). When we
later run migrate, ``_query_codex_plugins()`` reports the same plugins via
the live ``plugin/list`` RPC and we re-emit them inside the managed block.
The result without this strip is duplicate ``[plugins."X@Y"]`` table
headers — codex's strict TOML parser then refuses to load the file.
We own the ``[plugins.*]`` namespace once migrate has run, so dropping any
pre-existing ``[plugins.*]`` tables is safe: ``plugin/list`` is the source
of truth for what's actually installed. The caller is expected to only
invoke this strip when ``plugin/list`` succeeded — otherwise we'd lose
plugins the user installed via ``codex`` without a way to re-emit them.
Behavior:
* Lines beginning with ``[plugins.`` start a swallow region that ends at
the next non-``[plugins.`` table header or end-of-file.
* Content inside the managed block is untouched (callers should run
``_strip_existing_managed_block`` first so the managed block has
already been removed when this runs).
"""
lines = toml_text.splitlines(keepends=True)
out: list[str] = []
in_plugin_table = False
for line in lines:
stripped = line.lstrip()
# Only treat a line as a table header when it has the shape
# ``[...]`` (optionally followed by a comment). Multi-line array
# continuations like ``["nested"],`` also start with ``[`` after
# lstrip but are not headers — without this guard they would
# falsely flip ``in_plugin_table`` to False mid-table and leak
# array fragments into the output.
if _looks_like_table_header(stripped):
in_plugin_table = stripped.startswith("[plugins.")
if in_plugin_table:
continue
if in_plugin_table:
# Swallow keys/comments/blanks until the next table header.
continue
out.append(line)
return "".join(out)
def _looks_like_table_header(stripped_line: str) -> bool:
"""Return True if ``stripped_line`` is a TOML table header.
A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables),
optionally followed by a comment. The closing ``]`` (or ``]]``) must
appear on the same line, and no key-assignment ``=`` can precede it.
This distinguishes real headers from multi-line array continuation
lines that also start with ``[`` after ``lstrip()``.
"""
if not stripped_line.startswith("["):
return False
# Drop trailing comment so e.g. ``[features] # note`` still matches.
head = stripped_line.split("#", 1)[0].rstrip()
if not head.endswith("]"):
return False
# ``key = [x]`` would have an ``=`` before the bracket; a header doesn't.
bracket_idx = head.index("]")
return "=" not in head[: bracket_idx + 1]
def _strip_existing_managed_block(toml_text: str) -> str:
"""Remove any prior managed section so re-runs idempotently replace it.
The managed section is everything between MIGRATION_MARKER (start) and
MIGRATION_END_MARKER (end), inclusive of both markers. User-edited
sections above or below are preserved verbatim.
Backward compatibility: if the start marker is found but no end marker
follows, we fall back to the heuristic that swallows lines until we
hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/
a `default_permissions =` key. This matches what older versions of
this code wrote so re-runs don't break configs from prior Hermes
versions."""
lines = toml_text.splitlines(keepends=True)
out: list[str] = []
in_managed = False
saw_end_marker = False
for line in lines:
line_stripped_nl = line.rstrip("\n")
if line_stripped_nl == MIGRATION_MARKER:
in_managed = True
saw_end_marker = False
continue
if in_managed:
if line_stripped_nl == MIGRATION_END_MARKER:
in_managed = False
saw_end_marker = True
continue
stripped = line.lstrip()
if not saw_end_marker and stripped.startswith("[") and not (
stripped.startswith("[mcp_servers")
or stripped.startswith("[plugins")
or stripped.startswith("[permissions]")
or stripped.startswith("[permissions.")
):
# Old-format managed block without end marker: bail back
# to user content as soon as we see a non-managed section.
in_managed = False
out.append(line)
continue
# Otherwise swallow the line.
continue
out.append(line)
return "".join(out)
def _query_codex_plugins(
codex_home: Optional[Path] = None,
timeout: float = 8.0,
) -> tuple[list[dict], Optional[str]]:
"""Query codex's `plugin/list` for installed curated plugins.
Spawns `codex app-server` briefly, sends initialize + plugin/list,
extracts plugins where installed=true. Returns (plugins, error).
Plugins is a list of {name, marketplace, enabled} dicts ready for
render_codex_toml_section().
On any failure (codex not installed, RPC error, timeout) returns
([], error_message). Migration treats this as non-fatal — MCP
servers and permissions still write through.
"""
try:
from agent.transports.codex_app_server import CodexAppServerClient
except Exception as exc:
return [], f"transport unavailable: {exc}"
try:
with CodexAppServerClient(
codex_home=str(codex_home) if codex_home else None
) as client:
client.initialize(client_name="hermes-migration")
resp = client.request("plugin/list", {}, timeout=timeout)
except Exception as exc:
return [], f"plugin/list query failed: {exc}"
out: list[dict] = []
seen: set[tuple[str, str]] = set()
marketplaces = resp.get("marketplaces") or []
if not isinstance(marketplaces, list):
return [], "plugin/list response missing 'marketplaces'"
for marketplace in marketplaces:
if not isinstance(marketplace, dict):
continue
market_name = str(marketplace.get("name") or "openai-curated")
plugins = marketplace.get("plugins") or []
if not isinstance(plugins, list):
continue
for plugin in plugins:
if not isinstance(plugin, dict):
continue
installed = bool(plugin.get("installed", False))
if not installed:
continue
# Skip plugins codex itself reports as unavailable (broken
# install, missing OAuth, removed from marketplace, etc.).
# Cf. openclaw/openclaw#80815 — OpenClaw learned to gate
# migration on app readiness to avoid writing config that
# would fail at activation time. Our migration writes to
# codex's config.toml directly, so a broken plugin would
# surface as a codex error on first use. Skipping it here
# keeps the migrated config clean and the user's first
# codex turn from failing.
availability = str(plugin.get("availability") or "").upper()
if availability and availability != "AVAILABLE":
logger.debug(
"skipping plugin %s: availability=%s",
plugin.get("name"), availability,
)
continue
name = str(plugin.get("name") or "")
if not name:
continue
key = (name, market_name)
if key in seen:
continue
seen.add(key)
# Carry forward whatever 'enabled' codex reports — defaults to
# true for installed plugins. This is the same shape OpenClaw
# writes when migrating native codex plugins.
out.append({
"name": name,
"marketplace": market_name,
"enabled": bool(plugin.get("enabled", True)),
})
return out, None
def _looks_like_test_tempdir(path: str) -> bool:
"""Heuristic: does ``path`` look like a pytest/transient tempdir?
pytest tempdirs live under ``pytest-of-<user>/pytest-<n>/`` (created via
``tmp_path`` / ``tmp_path_factory``) and are reaped between sessions.
macOS routes ``/tmp`` through ``/private/var/folders/<…>/T`` which is
what pytest's tempdir factory uses by default. If a HERMES_HOME pointing
at one of those paths is burned into ``~/.codex/config.toml``, every
codex-routed hermes-tools call fails silently once the directory is GC'd.
We err on the side of refusing — losing a (very unlikely) real
``~/.hermes`` symlink that happens to live under ``/private/var/folders``
is much less harmful than silently bricking codex's tool surface.
"""
if not path:
return False
needles = (
"pytest-of-",
"/pytest-",
"/tmp/pytest",
"/private/var/folders/", # macOS tempdir root
)
normalized = path.lower()
return any(needle in normalized for needle in needles)
def _build_hermes_tools_mcp_entry() -> dict:
"""Build the codex stdio-transport entry that launches Hermes' own
tool surface as an MCP server. Codex's subprocess will call back into
this for browser/web/delegate_task/vision/memory/skills tools.
The command runs the worktree's Python via the current sys.executable
so a hermes installed under /opt/, /usr/local/, or a venv all work.
HERMES_HOME and PYTHONPATH are passed through so the spawned process
sees the same config + module layout the user is running."""
import sys
env: dict[str, str] = {}
# HERMES_HOME passes through IF SET so the MCP subprocess sees the same
# config / auth / sessions DB as the parent CLI. Read from os.environ
# (not get_hermes_home()) on purpose: when the env var is unset we want
# codex's subprocess to inherit whatever HERMES_HOME its launcher sets
# at runtime (systemd unit, gateway, kanban dispatcher, custom shell),
# rather than burning the migrate-time resolved default into config.toml
# — that would override the launcher's HERMES_HOME and pin the subprocess
# to the wrong profile.
#
# The pytest-tempdir guard below catches the issue #26250 Bug C scenario:
# a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would
# otherwise leak a transient pytest tempdir into the user's real
# ~/.codex/config.toml and silently brick codex once the tempdir is GC'd.
hermes_home = os.environ.get("HERMES_HOME") or ""
if hermes_home and _looks_like_test_tempdir(hermes_home):
hermes_home = ""
if hermes_home:
env["HERMES_HOME"] = hermes_home
# PYTHONPATH passes through so a worktree-launched hermes finds the
# branch's modules instead of the installed package.
pythonpath = os.environ.get("PYTHONPATH")
if pythonpath:
env["PYTHONPATH"] = pythonpath
# Quiet mode + redaction defaults so the MCP wire stays clean.
env["HERMES_QUIET"] = "1"
env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true")
out: dict[str, Any] = {
"command": sys.executable,
"args": ["-m", "agent.transports.hermes_tools_mcp_server"],
}
if env:
out["env"] = env
# Generous timeouts — browser_navigate or delegate_task can take a
# while; we don't want codex's MCP client to give up too early.
out["startup_timeout_sec"] = 30.0
out["tool_timeout_sec"] = 600.0
return out
def migrate(
hermes_config: dict,
*,
codex_home: Optional[Path] = None,
dry_run: bool = False,
discover_plugins: bool = True,
default_permission_profile: Optional[str] = ":workspace",
expose_hermes_tools: bool = True,
) -> MigrationReport:
"""Translate Hermes mcp_servers config + Codex curated plugins into
~/.codex/config.toml.
Args:
hermes_config: full ~/.hermes/config.yaml dict
codex_home: override CODEX_HOME (defaults to ~/.codex)
dry_run: skip the actual write; report what would happen
discover_plugins: when True (default), query `plugin/list` against
the live codex CLI to migrate any installed curated plugins
into [plugins."<name>@<marketplace>"] entries. Set False to
skip the subprocess spawn (for tests or restricted environments).
default_permission_profile: when set (default ":workspace"), write
top-level `default_permissions = "<name>"` so users on this
runtime don't get an approval prompt on every write attempt.
Built-in codex profile names are ":workspace", ":read-only",
":danger-no-sandbox" (note the leading ":"). Also accepts a
user-defined profile name (no leading ":") that the user has
configured in their own [permissions.<name>] table. Set None
to leave permissions unset and let codex use its compiled-in
default (which is read-only).
expose_hermes_tools: when True (default), register Hermes' own
tool surface (web_search, browser_*, delegate_task, vision,
memory, skills, etc.) as an MCP server in ~/.codex/config.toml
so the codex subprocess can call back into Hermes for tools
codex doesn't have built in. Set False to opt out.
"""
report = MigrationReport(dry_run=dry_run)
codex_home = codex_home or Path.home() / ".codex"
target = codex_home / "config.toml"
report.target_path = target
hermes_servers = (hermes_config or {}).get("mcp_servers") or {}
if not isinstance(hermes_servers, dict):
report.errors.append(
"mcp_servers in Hermes config is not a dict; cannot migrate."
)
return report
translated: dict[str, dict] = {}
for name, cfg in hermes_servers.items():
out, skipped = _translate_one_server(str(name), cfg or {})
if out is None:
report.errors.append(
f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}"
)
continue
translated[str(name)] = out
if skipped:
report.skipped_keys_per_server[str(name)] = skipped
report.migrated.append(str(name))
# Discover installed Codex curated plugins. Best-effort — never blocks
# the migration if codex is unreachable or the RPC fails.
plugins: list[dict] = []
plugin_query_succeeded = False
if discover_plugins and not dry_run:
plugins, plugin_err = _query_codex_plugins(codex_home=codex_home)
if plugin_err:
report.plugin_query_error = plugin_err
else:
# plugin/list returned authoritatively (even if the list is empty).
# That means we own [plugins.*] for this re-render and can safely
# strip any pre-existing tables outside the managed block.
plugin_query_succeeded = True
for p in plugins:
report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}")
# Track whether we wrote a default permission profile so the report
# surfaces it to the user.
if default_permission_profile:
report.wrote_permissions_default = default_permission_profile
# Inject Hermes' own tool surface as an MCP server so the spawned
# codex subprocess can call back into Hermes for the tools codex
# doesn't ship with — web_search, browser_*, delegate_task, vision,
# memory, skills, session_search, image_generate, text_to_speech.
# The server itself is agent/transports/hermes_tools_mcp_server.py
# and is launched on demand by codex (stdio MCP).
if expose_hermes_tools:
translated["hermes-tools"] = _build_hermes_tools_mcp_entry()
if "hermes-tools" not in report.migrated:
report.migrated.append("hermes-tools")
# Build the new managed block
managed_block = render_codex_toml_section(
translated, plugins=plugins,
default_permission_profile=default_permission_profile,
)
# Read existing codex config if any, strip the prior managed block,
# append the new one.
if target.exists():
try:
existing = target.read_text(encoding="utf-8")
except Exception as exc:
report.errors.append(f"could not read {target}: {exc}")
return report
without_managed = _strip_existing_managed_block(existing)
# Bug B: when plugin/list ran authoritatively, codex's own
# [plugins."<name>@<marketplace>"] tables outside our managed block
# would survive _strip_existing_managed_block and then collide with
# the entries we re-emit inside the managed block — producing
# duplicate-table-header parse errors on codex's next startup. Drop
# those pre-existing tables since plugin/list is the source of truth.
if plugin_query_succeeded:
without_managed = _strip_unmanaged_plugin_tables(without_managed)
new_text = _insert_managed_block_at_top_level(without_managed, managed_block)
else:
new_text = managed_block
if dry_run:
return report
try:
codex_home.mkdir(parents=True, exist_ok=True)
# Atomic write: write to a temp file in the same directory then
# rename. Same-directory rename is atomic on POSIX and ReplaceFile
# on Windows. Avoids leaving a half-written config.toml that
# codex would refuse to load if we crash mid-write.
import tempfile
tmp_fd, tmp_path_str = tempfile.mkstemp(
prefix=".config.toml.", dir=str(codex_home)
)
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh:
fh.write(new_text)
tmp_path.replace(target)
except Exception:
# Clean up the temp file if the rename didn't happen.
try:
if tmp_path.exists():
tmp_path.unlink()
except Exception:
pass
raise
report.written = True
except Exception as exc:
report.errors.append(f"could not write {target}: {exc}")
return report
+266
View File
@@ -0,0 +1,266 @@
"""Shared logic for the /codex-runtime slash command.
Toggles `model.openai_runtime` between "auto" (= chat_completions, Hermes'
default) and "codex_app_server" (= hand turns to a codex subprocess).
Both CLI (cli.py) and gateway (gateway/run.py) call into this module so the
behavior stays identical across surfaces.
The actual runtime resolution happens in hermes_cli.runtime_provider's
_maybe_apply_codex_app_server_runtime() helper, which reads the persisted
config value. This module just persists the value and reports the change.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
VALID_RUNTIMES = ("auto", "codex_app_server")
@dataclass
class CodexRuntimeStatus:
"""Result of a /codex-runtime invocation. Callers render this however
suits their surface (CLI uses Rich panels, gateway sends a text message)."""
success: bool
new_value: Optional[str] = None
old_value: Optional[str] = None
message: str = ""
requires_new_session: bool = False
codex_binary_ok: bool = True
codex_version: Optional[str] = None
def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]:
"""Parse the slash-command argument string. Returns (value, errors).
No args return current state (value=None)
'auto' / 'codex_app_server' / 'on' / 'off' return that value
anything else error
"""
raw = (arg_string or "").strip().lower()
if not raw:
return None, []
# Accept human-friendly synonyms
if raw in ("on", "codex", "enable"):
return "codex_app_server", []
if raw in ("off", "default", "disable", "hermes"):
return "auto", []
if raw in VALID_RUNTIMES:
return raw, []
return None, [
f"Unknown runtime {raw!r}. Use one of: auto, codex_app_server, on, off"
]
def get_current_runtime(config: dict) -> str:
"""Read the current `model.openai_runtime` value from a config dict.
Returns 'auto' for unset / empty / unrecognized values."""
if not isinstance(config, dict):
return "auto"
model_cfg = config.get("model") or {}
if not isinstance(model_cfg, dict):
return "auto"
value = str(model_cfg.get("openai_runtime") or "").strip().lower()
if value in VALID_RUNTIMES:
return value
return "auto"
def set_runtime(config: dict, new_value: str) -> str:
"""Mutate the config dict in place to persist the new runtime value.
Returns the previous value for callers that want to report a delta."""
if new_value not in VALID_RUNTIMES:
raise ValueError(
f"invalid runtime {new_value!r}; must be one of {VALID_RUNTIMES}"
)
old = get_current_runtime(config)
if not isinstance(config.get("model"), dict):
config["model"] = {}
config["model"]["openai_runtime"] = new_value
return old
def check_codex_binary_ok() -> tuple[bool, Optional[str]]:
"""Best-effort verification that codex CLI is installed at acceptable
version. Returns (ok, version_or_message)."""
try:
from agent.transports.codex_app_server import check_codex_binary
return check_codex_binary()
except Exception as exc: # pragma: no cover
return False, f"codex check failed: {exc}"
def apply(
config: dict,
new_value: Optional[str],
*,
persist_callback=None,
) -> CodexRuntimeStatus:
"""Top-level entry point used by both CLI and gateway handlers.
Args:
config: in-memory config dict (will be mutated when new_value is set)
new_value: desired runtime; None means "show current state only"
persist_callback: optional callable taking the mutated config dict
and persisting it to disk. Skipped when None (used by tests).
Returns: CodexRuntimeStatus describing the outcome.
"""
current = get_current_runtime(config)
# Cache the codex binary check for this apply() call. Subprocess spawn
# is cheap (~50ms for `codex --version`), but we'd otherwise call it up
# to 3 times in the enable path (read-only/state, gate, success message).
# None = not yet checked; (bool, str) = result.
_binary_check: Optional[tuple[bool, Optional[str]]] = None
def _check_binary_cached() -> tuple[bool, Optional[str]]:
nonlocal _binary_check
if _binary_check is None:
_binary_check = check_codex_binary_ok()
return _binary_check
# Read-only call: just report state
if new_value is None:
ok, ver = _check_binary_cached()
msg = (
f"openai_runtime: {current}\n"
f"codex CLI: {'OK ' + ver if ok else 'not available — ' + (ver or 'install with `npm i -g @openai/codex`')}"
)
return CodexRuntimeStatus(
success=True,
new_value=current,
old_value=current,
message=msg,
codex_binary_ok=ok,
codex_version=ver if ok else None,
)
# No change requested
if new_value == current:
return CodexRuntimeStatus(
success=True,
new_value=current,
old_value=current,
message=f"openai_runtime already set to {current}",
)
# If switching ON, verify codex CLI is installed before persisting —
# an opt-in toggle that silently fails on the first turn is the
# worst possible UX. Block here with a clear install hint.
if new_value == "codex_app_server":
ok, ver_or_msg = _check_binary_cached()
if not ok:
return CodexRuntimeStatus(
success=False,
new_value=None,
old_value=current,
message=(
"Cannot enable codex_app_server runtime: "
f"{ver_or_msg or 'codex CLI not available'}\n"
"Install with: npm i -g @openai/codex"
),
codex_binary_ok=False,
codex_version=None,
)
set_runtime(config, new_value)
if persist_callback is not None:
try:
persist_callback(config)
except Exception as exc:
logger.exception("failed to persist openai_runtime change")
return CodexRuntimeStatus(
success=False,
new_value=new_value,
old_value=current,
message=f"updated config in memory but persist failed: {exc}",
)
msg_lines = [
f"openai_runtime: {current}{new_value}",
]
if new_value == "codex_app_server":
ok, ver = _check_binary_cached()
if ok:
msg_lines.append(f"codex CLI: {ver}")
# Auto-migrate Hermes' MCP servers + Codex's installed curated
# plugins into ~/.codex/config.toml so the spawned codex subprocess
# sees the same tool surface AND can call back into Hermes for
# browser/web/delegate_task/vision/memory tools (#7 fix).
# Failures are non-fatal — the runtime change still proceeds.
try:
from hermes_cli.codex_runtime_plugin_migration import migrate
mig_report = migrate(config)
# Tools/MCP servers (excluding the hermes-tools callback,
# which is internal plumbing — surface separately).
user_servers = [
s for s in mig_report.migrated if s != "hermes-tools"
]
if user_servers:
msg_lines.append(
f"Migrated {len(user_servers)} MCP server(s): "
f"{', '.join(user_servers)}"
)
# Native Codex plugin migration (Linear, GitHub, etc.)
if mig_report.migrated_plugins:
msg_lines.append(
f"Migrated {len(mig_report.migrated_plugins)} native "
f"Codex plugin(s): {', '.join(mig_report.migrated_plugins)}"
)
elif mig_report.plugin_query_error:
msg_lines.append(
f"Codex plugin discovery skipped: "
f"{mig_report.plugin_query_error}"
)
# Permissions + Hermes tool callback are always-on production
# bits the user benefits from knowing about.
if mig_report.wrote_permissions_default:
msg_lines.append(
f"Default sandbox: {mig_report.wrote_permissions_default} "
f"(no approval prompt on every write)"
)
if "hermes-tools" in mig_report.migrated:
msg_lines.append(
"Hermes tool callback registered: codex can now use "
"web_search, web_extract, browser_*, vision_analyze, "
"image_generate, skill_view, skills_list, text_to_speech, "
"kanban_* (worker + orchestrator) via MCP."
)
msg_lines.append(
" (delegate_task, memory, session_search, todo run "
"only on the default Hermes runtime — they need the "
"agent loop context.)"
)
msg_lines.append(f" (config: {mig_report.target_path})")
for err in mig_report.errors:
msg_lines.append(f"⚠ MCP migration: {err}")
except Exception as exc:
msg_lines.append(f"⚠ MCP migration skipped: {exc}")
msg_lines.append(
"OpenAI/Codex turns now run through `codex app-server` "
"(terminal/file ops/patching inside Codex; "
"Hermes tools available via MCP callback)."
)
msg_lines.append(
"Effective on next session — current cached agent keeps "
"the prior runtime to preserve prompt cache."
)
else:
msg_lines.append("OpenAI/Codex turns will use the default Hermes runtime.")
msg_lines.append("Effective on next session.")
return CodexRuntimeStatus(
success=True,
new_value=new_value,
old_value=current,
message="\n".join(msg_lines),
requires_new_session=True,
)
+4
View File
@@ -104,6 +104,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="<prompt>"),
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
args_hint="[text | pause | resume | clear | status]"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
CommandDef("status", "Show session info", "Session"),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info"),
@@ -120,6 +122,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", args_hint="[auto|codex_app_server]"),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
cli_only=True),
+1 -1
View File
@@ -238,7 +238,7 @@ _hermes() {{
esac
}}
_hermes "$@"
compdef _hermes hermes
"""
+81 -23
View File
@@ -731,6 +731,12 @@ DEFAULT_CONFIG = {
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
"protect_last_n": 20, # minimum recent messages to keep uncompressed
"hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count
"protect_first_n": 3, # non-system head messages always preserved
# verbatim, in ADDITION to the system prompt
# (which is always implicitly protected). Set to
# 0 for long-running rolling-compaction sessions
# where you want nothing pinned except the
# system prompt + rolling summary + recent tail.
},
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
@@ -971,6 +977,21 @@ DEFAULT_CONFIG = {
# Web dashboard settings
"dashboard": {
"theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose"
# Hide the token/cost analytics surfaces (Analytics page, token bars and
# cost figures on the Models page) by default. The numbers shown there
# are a local debug estimate: they only count successful main-agent
# responses with a usable ``response.usage``, and silently exclude every
# auxiliary call (context compression, title generation, vision,
# session search, web extract, smart approval, MCP routing, plugin LLM
# access) plus provider-side retries, fallback attempts, and any call
# whose usage block didn't come back. Cache writes are also missing
# from the API response. On models with heavy auxiliary traffic
# (Kimi K2.6, MiniMax M2.7) the local total can be 10x-100x lower than
# the provider bill, which is worse than hiding the numbers entirely
# because they look precise enough to compare against the provider.
# Set this to True to re-enable the surfaces with the understanding
# that the numbers are a local lower-bound estimate, not billing.
"show_token_analytics": False,
},
# Privacy settings
@@ -1235,6 +1256,9 @@ DEFAULT_CONFIG = {
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
"history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out)
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
@@ -2113,26 +2137,10 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
},
"FAL_KEY": {
"description": "FAL API key for image generation",
"description": "FAL API key for image and video generation",
"prompt": "FAL API key",
"url": "https://fal.ai/",
"tools": ["image_generate"],
"password": True,
"category": "tool",
},
"TINKER_API_KEY": {
"description": "Tinker API key for RL training",
"prompt": "Tinker API key",
"url": "https://tinker-console.thinkingmachines.ai/keys",
"tools": ["rl_start_training", "rl_check_status", "rl_stop_training"],
"password": True,
"category": "tool",
},
"WANDB_API_KEY": {
"description": "Weights & Biases API key for experiment tracking",
"prompt": "WandB API key",
"url": "https://wandb.ai/authorize",
"tools": ["rl_get_results", "rl_check_status"],
"tools": ["image_generate", "video_generate"],
"password": True,
"category": "tool",
},
@@ -4326,10 +4334,34 @@ def load_env() -> Dict[str, str]:
concatenated KEY=VALUE pairs on a single line) are handled
gracefully instead of producing mangled values such as duplicated
bot tokens. See #8908.
The parsed dict is memoised keyed on the .env file mtime, because
``get_env_value()`` is called dozens-to-hundreds of times per
interactive menu render (`hermes tools`, `hermes setup`, status
panels). Sanitisation is O(lines × known-keys), so re-parsing the
same file on every call was burning ~300ms of CPU per `hermes tools`
menu paint on top of the OAuth-refresh slowness. The mtime check
invalidates the cache when the user edits .env mid-process.
"""
global _env_cache
env_path = get_env_path()
env_vars = {}
try:
mtime = env_path.stat().st_mtime
size = env_path.stat().st_size
cache_key = (str(env_path), mtime, size)
except FileNotFoundError:
cache_key = (str(env_path), None, None)
except Exception:
cache_key = None
if cache_key is not None and _env_cache is not None:
cached_key, cached_vars = _env_cache
if cached_key == cache_key:
return dict(cached_vars)
env_vars: Dict[str, str] = {}
if env_path.exists():
# On Windows, open() defaults to the system locale (cp1252) which can
# fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM
@@ -4345,10 +4377,33 @@ def load_env() -> Dict[str, str]:
if line and not line.startswith('#') and '=' in line:
key, _, value = line.partition('=')
env_vars[key.strip()] = value.strip().strip('"\'')
if cache_key is not None:
_env_cache = (cache_key, dict(env_vars))
return env_vars
# Module-level memo for load_env(), keyed on (path, mtime, size).
# Editing .env bumps mtime → next load_env() rebuilds. invalidate_env_cache()
# is the explicit knob for writers that update .env via this module
# (set_env_value, save_env, etc.) without relying on filesystem mtime
# resolution.
_env_cache: Optional[Tuple[Tuple[str, Optional[float], Optional[int]], Dict[str, str]]] = None
def invalidate_env_cache() -> None:
"""Clear the load_env() process-level memo.
Writers that mutate .env (set_env_value, save_env, etc.) call this
to guarantee the next load_env() sees their change even on
filesystems with coarse mtime resolution. Reads invalidate naturally
via the mtime/size check.
"""
global _env_cache
_env_cache = None
def _sanitize_env_lines(lines: list) -> list:
"""Fix corrupted .env lines before reading or writing.
@@ -4451,6 +4506,7 @@ def sanitize_env_file() -> int:
pass
raise
_secure_file(env_path)
invalidate_env_cache()
return fixes
@@ -4562,6 +4618,7 @@ def save_env_value(key: str, value: str):
_secure_file(env_path)
os.environ[key] = value
invalidate_env_cache()
def remove_env_value(key: str) -> bool:
@@ -4617,6 +4674,7 @@ def remove_env_value(key: str) -> bool:
_secure_file(env_path)
os.environ.pop(key, None)
invalidate_env_cache()
return found
@@ -4803,6 +4861,7 @@ def show_config():
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages")
_aux_comp = config.get('auxiliary', {}).get('compression', {})
_sm = _aux_comp.get('model', '') or '(auto)'
print(f" Model: {_sm}")
@@ -4922,8 +4981,7 @@ def set_config_value(key: str, value: str):
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY', 'WANDB_API_KEY',
'TINKER_API_KEY',
'GITHUB_TOKEN', 'HONCHO_API_KEY',
]
if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'):
+8 -2
View File
@@ -196,9 +196,15 @@ def cron_create(args):
def cron_edit(args):
from cron.jobs import get_job
from cron.jobs import AmbiguousJobReference, resolve_job_ref
job = get_job(args.job_id)
try:
job = resolve_job_ref(args.job_id)
except AmbiguousJobReference as exc:
print(color(str(exc), Colors.RED))
for m in exc.matches:
print(f" {m['id']} (name: {m.get('name')!r})")
return 1
if not job:
print(color(f"Job not found: {args.job_id}", Colors.RED))
return 1
-22
View File
@@ -1595,28 +1595,6 @@ def run_doctor(args):
for _issue in _r.issues:
issues.append(_issue)
# =========================================================================
# Check: Submodules
# =========================================================================
print()
print(color("◆ Submodules", Colors.CYAN, Colors.BOLD))
# tinker-atropos (RL training backend)
tinker_dir = PROJECT_ROOT / "tinker-atropos"
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
if py_version >= (3, 11):
try:
__import__("tinker_atropos")
check_ok("tinker-atropos", "(RL training backend)")
except ImportError:
install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos"
check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})")
issues.append(f"Install tinker-atropos: {install_cmd}")
else:
check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
else:
check_warn("tinker-atropos not found", "(run: git submodule update --init --recursive)")
# =========================================================================
# Check: Tool Availability
# =========================================================================
+174 -12
View File
@@ -33,8 +33,8 @@ import json
import logging
import re
import time
from dataclasses import dataclass, asdict
from typing import Any, Dict, Optional, Tuple
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -45,6 +45,16 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_TURNS = 20
DEFAULT_JUDGE_TIMEOUT = 30.0
# Judge output budget. The freeform judge returns a one-line JSON verdict, but
# reasoning models (deepseek-v4, qwq, etc.) burn tokens on hidden reasoning
# before emitting the visible JSON — and the first /goal turn's prompt is
# larger than later turns, which pushes total reply length past tight caps.
# 200 tokens (the original default) reliably truncated the JSON on reasoning
# models, leaving '{"done": true, "reason": "The agent successfully' and
# triggering the auto-pause. 4096 covers reasoning + verdict on every model
# we've live-tested; override via auxiliary.goal_judge.max_tokens for
# specifically constrained setups.
DEFAULT_JUDGE_MAX_TOKENS = 4096
# Cap how much of the last response + recent messages we send to the judge.
_JUDGE_RESPONSE_SNIPPET_CHARS = 4000
# After this many consecutive judge *parse* failures (empty output / non-JSON),
@@ -65,6 +75,21 @@ CONTINUATION_PROMPT_TEMPLATE = (
"If you are blocked and need input from the user, say so clearly and stop."
)
# Used when the user has added one or more /subgoal criteria. Surfaced
# to the agent verbatim so it sees what to target on the next turn,
# and surfaced to the judge so the verdict considers them too.
CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = (
"[Continuing toward your standing goal]\n"
"Goal: {goal}\n\n"
"Additional criteria the user added mid-loop:\n"
"{subgoals_block}\n\n"
"Continue working toward the goal AND all additional criteria. Take "
"the next concrete step. If you believe the goal and every "
"additional criterion are complete, state so explicitly and stop. "
"If you are blocked and need input from the user, say so clearly "
"and stop."
)
JUDGE_SYSTEM_PROMPT = (
"You are a strict judge evaluating whether an autonomous agent has "
@@ -88,6 +113,23 @@ JUDGE_USER_PROMPT_TEMPLATE = (
"Is the goal satisfied?"
)
# Used when the user has added /subgoal criteria. The judge must
# evaluate ALL of them being met, not just the original goal.
JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE = (
"Goal:\n{goal}\n\n"
"Additional criteria the user added mid-loop (all must also be "
"satisfied for the goal to be DONE):\n{subgoals_block}\n\n"
"Agent's most recent response:\n{response}\n\n"
"Decision: For each numbered criterion above, find concrete "
"evidence in the agent's response that the criterion is "
"satisfied. Do not accept generic phrases like 'all requirements "
"met' or 'implying it was done' — require specific evidence (a "
"file contents excerpt, an output line, a command result). If "
"ANY criterion lacks specific evidence in the response, the goal "
"is NOT done — return CONTINUE.\n\n"
"Is the goal AND every additional criterion satisfied?"
)
# ──────────────────────────────────────────────────────────────────────
# Dataclass
@@ -108,6 +150,12 @@ class GoalState:
last_reason: Optional[str] = None
paused_reason: Optional[str] = None # why we auto-paused (budget, etc.)
consecutive_parse_failures: int = 0 # judge-output parse failures in a row
# User-added criteria appended mid-loop via the /subgoal command.
# When non-empty the judge prompt and continuation prompt both
# include them so the agent works toward them and the judge factors
# them into the verdict. Backwards-compatible: defaults to empty so
# old state_meta rows load unchanged.
subgoals: List[str] = field(default_factory=list)
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
@@ -115,6 +163,10 @@ class GoalState:
@classmethod
def from_json(cls, raw: str) -> "GoalState":
data = json.loads(raw)
raw_subgoals = data.get("subgoals") or []
subgoals: List[str] = []
if isinstance(raw_subgoals, list):
subgoals = [str(s).strip() for s in raw_subgoals if str(s).strip()]
return cls(
goal=data.get("goal", ""),
status=data.get("status", "active"),
@@ -126,8 +178,18 @@ class GoalState:
last_reason=data.get("last_reason"),
paused_reason=data.get("paused_reason"),
consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0),
subgoals=subgoals,
)
# --- subgoals helpers -------------------------------------------------
def render_subgoals_block(self) -> str:
"""Render the subgoals as a numbered ``- N. text`` block. Empty
when no subgoals exist."""
if not self.subgoals:
return ""
return "\n".join(f"- {i}. {text}" for i, text in enumerate(self.subgoals, start=1))
# ──────────────────────────────────────────────────────────────────────
# Persistence (SessionDB state_meta)
@@ -230,6 +292,30 @@ def _truncate(text: str, limit: int) -> str:
_JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL)
def _goal_judge_max_tokens() -> int:
"""Resolve auxiliary.goal_judge.max_tokens, falling back to the default.
``load_config()`` is cached on the config file's (mtime, size), so calling
this once per judge turn is cheap. A non-positive or non-int value falls
back to the default rather than crashing the goal loop.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
value = (
(cfg.get("auxiliary") or {})
.get("goal_judge", {})
.get("max_tokens", DEFAULT_JUDGE_MAX_TOKENS)
)
value = int(value)
if value > 0:
return value
except Exception:
pass
return DEFAULT_JUDGE_MAX_TOKENS
def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]:
"""Parse the judge's reply. Fail-open to ``(False, "<reason>", parse_failed)``.
@@ -284,6 +370,7 @@ def judge_goal(
last_response: str,
*,
timeout: float = DEFAULT_JUDGE_TIMEOUT,
subgoals: Optional[List[str]] = None,
) -> Tuple[str, str, bool]:
"""Ask the auxiliary model whether the goal is satisfied.
@@ -296,6 +383,11 @@ def judge_goal(
auto-pause after N consecutive parse failures (see
``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).
``subgoals`` is an optional list of user-added criteria (from
``/subgoal``) that the judge must also factor into its DONE/CONTINUE
decision. When non-empty the prompt switches to the with-subgoals
template; otherwise behavior is identical to the original judge.
This is deliberately fail-open: any error returns ``("continue", "...", False)``
so a broken judge doesn't wedge progress — the turn budget and the
consecutive-parse-failures auto-pause are the backstops.
@@ -321,10 +413,22 @@ def judge_goal(
if client is None or not model:
return "continue", "no auxiliary client configured", False
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
goal=_truncate(goal, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
)
# Build the prompt — pick the with-subgoals variant when applicable.
clean_subgoals = [s.strip() for s in (subgoals or []) if s and s.strip()]
if clean_subgoals:
subgoals_block = "\n".join(
f"- {i}. {text}" for i, text in enumerate(clean_subgoals, start=1)
)
prompt = JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE.format(
goal=_truncate(goal, 2000),
subgoals_block=_truncate(subgoals_block, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
)
else:
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
goal=_truncate(goal, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
)
try:
resp = client.chat.completions.create(
@@ -334,7 +438,7 @@ def judge_goal(
{"role": "user", "content": prompt},
],
temperature=0,
max_tokens=200,
max_tokens=_goal_judge_max_tokens(),
timeout=timeout,
extra_body=get_auxiliary_extra_body() or None,
)
@@ -397,14 +501,15 @@ class GoalManager:
if s is None or s.status in {"cleared",}:
return "No active goal. Set one with /goal <text>."
turns = f"{s.turns_used}/{s.max_turns} turns"
sub = f", {len(s.subgoals)} subgoal{'s' if len(s.subgoals) != 1 else ''}" if s.subgoals else ""
if s.status == "active":
return f"⊙ Goal (active, {turns}): {s.goal}"
return f"⊙ Goal (active, {turns}{sub}): {s.goal}"
if s.status == "paused":
extra = f"{s.paused_reason}" if s.paused_reason else ""
return f"⏸ Goal (paused, {turns}{extra}): {s.goal}"
return f"⏸ Goal (paused, {turns}{sub}{extra}): {s.goal}"
if s.status == "done":
return f"✓ Goal done ({turns}): {s.goal}"
return f"Goal ({s.status}, {turns}): {s.goal}"
return f"✓ Goal done ({turns}{sub}): {s.goal}"
return f"Goal ({s.status}, {turns}{sub}): {s.goal}"
# --- mutation -----------------------------------------------------
@@ -457,6 +562,53 @@ class GoalManager:
self._state.last_reason = reason
save_goal(self.session_id, self._state)
# --- /subgoal user controls ---------------------------------------
def add_subgoal(self, text: str) -> str:
"""Append a user-added criterion to the active goal. Requires
``has_goal()``; raises ``RuntimeError`` otherwise.
Returns the cleaned text so the caller can show it back to the user.
"""
if self._state is None or not self.has_goal():
raise RuntimeError("no active goal")
text = (text or "").strip()
if not text:
raise ValueError("subgoal text is empty")
self._state.subgoals.append(text)
save_goal(self.session_id, self._state)
return text
def remove_subgoal(self, index_1based: int) -> str:
"""Remove a subgoal by 1-based index. Returns the removed text."""
if self._state is None or not self.has_goal():
raise RuntimeError("no active goal")
idx = int(index_1based) - 1
if idx < 0 or idx >= len(self._state.subgoals):
raise IndexError(
f"index out of range (1..{len(self._state.subgoals)})"
)
removed = self._state.subgoals.pop(idx)
save_goal(self.session_id, self._state)
return removed
def clear_subgoals(self) -> int:
"""Wipe all subgoals. Returns the previous count."""
if self._state is None or not self.has_goal():
raise RuntimeError("no active goal")
prev = len(self._state.subgoals)
self._state.subgoals = []
save_goal(self.session_id, self._state)
return prev
def render_subgoals(self) -> str:
"""Public helper for the /subgoal slash command."""
if self._state is None:
return "(no active goal)"
if not self._state.subgoals:
return "(no subgoals — use /subgoal <text> to add criteria)"
return self._state.render_subgoals_block()
# --- the main entry point called after every turn -----------------
def evaluate_after_turn(
@@ -494,7 +646,9 @@ class GoalManager:
state.turns_used += 1
state.last_turn_at = time.time()
verdict, reason, parse_failed = judge_goal(state.goal, last_response)
verdict, reason, parse_failed = judge_goal(
state.goal, last_response, subgoals=state.subgoals or None
)
state.last_verdict = verdict
state.last_reason = reason
@@ -579,6 +733,11 @@ class GoalManager:
def next_continuation_prompt(self) -> Optional[str]:
if not self._state or self._state.status != "active":
return None
if self._state.subgoals:
return CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE.format(
goal=self._state.goal,
subgoals_block=self._state.render_subgoals_block(),
)
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
@@ -586,6 +745,9 @@ __all__ = [
"GoalState",
"GoalManager",
"CONTINUATION_PROMPT_TEMPLATE",
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
"JUDGE_USER_PROMPT_TEMPLATE",
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
"DEFAULT_MAX_TURNS",
"load_goal",
"save_goal",
+240
View File
@@ -0,0 +1,240 @@
"""Provider/model inventory context — shared substrate for the dashboard
``/api/model/options``, the TUI ``model.options``/``model.save_key``
JSON-RPC handlers, and the interactive picker.
Before this module the three call-sites each duplicated:
1. The 17-LOC config-slice that pulls ``model.{default,name,provider,base_url}``,
``providers:``, and ``custom_providers:`` out of ``load_config()``;
2. The call into ``list_authenticated_providers`` with the resulting kwargs;
3. (TUI only) a 45-LOC post-pass that merges authenticated rows with
unconfigured ``CANONICAL_PROVIDERS`` rows and emits ``authenticated``/
``auth_type``/``key_env``/``warning`` hints for the picker UI.
Consolidating those three steps into one entry point eliminates two bugs
the duplicates were hiding:
- The dashboard read ``cfg.get("custom_providers")`` directly, missing the
v12+ keyed ``providers:`` form (which the TUI handled via
``get_compatible_custom_providers``).
- The TUI's canonical-merge keyed on ``is_user_defined`` to decide
ordering. Section 3 of ``list_authenticated_providers`` sets
``is_user_defined=True`` even for canonical slugs that appear in the
``providers:`` config dict, which silently demoted them to the tail of
the picker. ``_reorder_canonical`` keys on slug membership instead.
Substrate facts (verified May 2026):
- ``list_authenticated_providers`` already populates each row's
``models`` from the curated catalog (same source as the picker). Do
NOT call ``provider_model_ids()`` per row to "freshen" that bypasses
curation and pulls in non-agentic models (Nous /models returns ~400
IDs including TTS, embeddings, rerankers, image/video generators).
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Optional
# ─── Public types ───────────────────────────────────────────────────────
@dataclass(frozen=True)
class ConfigContext:
"""Snapshot of the model + provider config every inventory caller
needs. Built once via ``load_picker_context()``; the TUI overlays
live agent state via ``with_overrides()`` before passing through.
"""
current_provider: str
current_model: str
current_base_url: str
user_providers: dict
custom_providers: list
def with_overrides(
self,
*,
current_provider: Optional[str] = None,
current_model: Optional[str] = None,
current_base_url: Optional[str] = None,
) -> "ConfigContext":
"""Return a copy with truthy overrides applied.
Truthy-only because the TUI reads agent attributes that may be
empty strings before an agent is spawned empties must NOT
clobber the disk-config values.
"""
kw: dict = {}
if current_provider:
kw["current_provider"] = current_provider
if current_model:
kw["current_model"] = current_model
if current_base_url:
kw["current_base_url"] = current_base_url
return replace(self, **kw) if kw else self
def load_picker_context() -> ConfigContext:
"""Load the disk-config snapshot every consumer needs.
Replaces the inline 17-LOC config-slice that ``web_server.py`` and
``tui_gateway/server.py`` (×2 sites) used to do.
"""
from hermes_cli.config import get_compatible_custom_providers, load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
current_provider = model_cfg.get("provider", "") or ""
current_base_url = model_cfg.get("base_url", "") or ""
else:
# config.model can be a bare string in older configs.
current_model = str(model_cfg) if model_cfg else ""
current_provider = ""
current_base_url = ""
raw = cfg.get("providers")
return ConfigContext(
current_provider=current_provider,
current_model=current_model,
current_base_url=current_base_url,
user_providers=raw if isinstance(raw, dict) else {},
custom_providers=get_compatible_custom_providers(cfg),
)
# ─── Public: payload builder ────────────────────────────────────────────
def build_models_payload(
ctx: ConfigContext,
*,
include_unconfigured: bool = False,
picker_hints: bool = False,
canonical_order: bool = False,
max_models: int = 50,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
needs from a single substrate call.
Flags:
- ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
``list_authenticated_providers`` didn't emit (TUI uses this to show
the full provider universe in the picker).
- ``picker_hints``: add ``authenticated``/``auth_type``/``key_env``/
``warning`` per row (TUI ``ModelPickerDialog`` shape).
- ``canonical_order``: reorder canonical-slug rows to
``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go
last (TUI display order).
"""
from hermes_cli.model_switch import list_authenticated_providers
rows = list_authenticated_providers(
current_provider=ctx.current_provider,
current_base_url=ctx.current_base_url,
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
max_models=max_models,
)
if include_unconfigured:
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
if picker_hints:
_apply_picker_hints(rows)
if canonical_order:
rows = _reorder_canonical(rows)
return {
"providers": rows,
"model": ctx.current_model,
"provider": ctx.current_provider,
}
# ─── Internal: row post-processing ──────────────────────────────────────
def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
"""Build skeleton rows for canonical providers missing from ``rows``."""
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
seen = {r["slug"].lower() for r in rows}
cur = (ctx.current_provider or "").lower()
extras: list[dict] = []
for entry in CANONICAL_PROVIDERS:
if entry.slug.lower() in seen:
continue
extras.append(
{
"slug": entry.slug,
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
"is_current": entry.slug.lower() == cur,
"is_user_defined": False,
"models": [],
"total_models": 0,
"source": "canonical",
}
)
return extras
def _apply_picker_hints(rows: list[dict]) -> None:
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
Mutates ``rows`` in-place. Rows already from
``list_authenticated_providers`` are marked ``authenticated=True``;
the unconfigured skeleton rows from ``_append_unconfigured_rows`` get
the picker's setup-hint shape.
"""
from hermes_cli.auth import PROVIDER_REGISTRY
for row in rows:
if "authenticated" in row:
continue
# Distinguish authenticated rows (returned by
# list_authenticated_providers) from skeleton rows (from
# _append_unconfigured_rows). The skeleton rows have empty
# `models` AND source="canonical"; authenticated rows have
# populated `models` OR a non-canonical source.
is_skeleton = row.get("source") == "canonical" and not row.get("models")
row["authenticated"] = not is_skeleton
if not is_skeleton or row.get("is_user_defined"):
continue
cfg = PROVIDER_REGISTRY.get(row["slug"])
auth_type = cfg.auth_type if cfg else "api_key"
key_env = (
cfg.api_key_env_vars[0]
if (cfg and cfg.api_key_env_vars)
else ""
)
row["auth_type"] = auth_type
row["key_env"] = key_env
row["warning"] = (
f"paste {key_env} to activate"
if auth_type == "api_key" and key_env
else f"run `hermes model` to configure ({auth_type})"
)
def _reorder_canonical(rows: list[dict]) -> list[dict]:
"""Canonical slugs in ``CANONICAL_PROVIDERS`` declaration order;
truly-custom rows last.
Keys on slug membership, NOT ``is_user_defined`` section 3 of
``list_authenticated_providers`` sets ``is_user_defined=True`` on
rows from the ``providers:`` config dict even when the slug is
canonical. Keying on the flag would silently demote canonical
providers configured via the new keyed schema.
"""
from hermes_cli.models import CANONICAL_PROVIDERS
order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)}
canon = sorted(
(r for r in rows if r["slug"] in order),
key=lambda r: order[r["slug"]],
)
extras = [r for r in rows if r["slug"] not in order]
return canon + extras
+466 -62
View File
@@ -1469,6 +1469,17 @@ def cmd_gateway(args):
gateway_command(args)
def cmd_proxy(args):
"""Local OpenAI-compatible proxy to OAuth providers."""
# Lazy import — pulls in aiohttp, which is gated behind an extras install
# for users who don't run the proxy or the messaging gateway.
from hermes_cli.proxy.cli import cmd_proxy as _cmd_proxy
rc = _cmd_proxy(args)
if isinstance(rc, int) and rc != 0:
raise SystemExit(rc)
def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
_require_tty("whatsapp")
@@ -1938,6 +1949,8 @@ def select_provider_and_model(args=None):
_model_flow_nous(config, current_model, args=args)
elif selected_provider == "openai-codex":
_model_flow_openai_codex(config, current_model)
elif selected_provider == "xai-oauth":
_model_flow_xai_oauth(config, current_model)
elif selected_provider == "qwen-oauth":
_model_flow_qwen_oauth(config, current_model)
elif selected_provider == "minimax-oauth":
@@ -2431,30 +2444,31 @@ def _prompt_provider_choice(choices, *, default=0):
def _model_flow_openrouter(config, current_model=""):
"""OpenRouter provider: ensure API key, then pick model."""
from hermes_cli.auth import (
ProviderConfig,
_prompt_model_selection,
_save_model_choice,
deactivate_provider,
)
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.config import get_env_value
api_key = get_env_value("OPENROUTER_API_KEY")
if not api_key:
print("No OpenRouter API key configured.")
# Route through _prompt_api_key so users can replace a stale/broken key
# in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand. The
# previous bypass-when-key-exists branch left no way to recover from a
# bad paste short of re-running `hermes setup` from scratch. OpenRouter
# isn't in PROVIDER_REGISTRY so we synthesize a minimal pconfig.
pconfig = ProviderConfig(
id="openrouter",
name="OpenRouter",
auth_type="api_key",
api_key_env_vars=("OPENROUTER_API_KEY",),
)
existing_key = get_env_value("OPENROUTER_API_KEY") or ""
if not existing_key:
print("Get one at: https://openrouter.ai/keys")
print()
try:
import getpass
key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not key:
print("Cancelled.")
return
save_env_value("OPENROUTER_API_KEY", key)
print("API key saved.")
print()
_resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="openrouter")
if abort:
return
from hermes_cli.models import model_ids, get_pricing_for_provider
@@ -2490,33 +2504,26 @@ def _model_flow_openrouter(config, current_model=""):
def _model_flow_ai_gateway(config, current_model=""):
"""Vercel AI Gateway provider: ensure API key, then pick model with pricing."""
from hermes_cli.auth import (
PROVIDER_REGISTRY,
_prompt_model_selection,
_save_model_choice,
deactivate_provider,
)
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.config import get_env_value
api_key = get_env_value("AI_GATEWAY_API_KEY")
if not api_key:
print("No Vercel AI Gateway API key configured.")
# Route through _prompt_api_key so users can replace a stale/broken key
# in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand.
pconfig = PROVIDER_REGISTRY["ai-gateway"]
existing_key = get_env_value("AI_GATEWAY_API_KEY") or ""
if not existing_key:
print(
"Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway"
)
print("Add a payment method to get $5 in free credits.")
print()
try:
import getpass
key = getpass.getpass("AI Gateway API key (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not key:
print("Cancelled.")
return
save_env_value("AI_GATEWAY_API_KEY", key)
print("API key saved.")
print()
_resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="ai-gateway")
if abort:
return
from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider
@@ -2825,6 +2832,87 @@ def _model_flow_openai_codex(config, current_model=""):
print("No change.")
def _model_flow_xai_oauth(_config, current_model=""):
"""xAI Grok OAuth (SuperGrok Subscription) provider: ensure logged in, then pick model."""
from hermes_cli.auth import (
get_xai_oauth_auth_status,
_prompt_model_selection,
_save_model_choice,
_update_config_for_provider,
resolve_xai_oauth_runtime_credentials,
_login_xai_oauth,
DEFAULT_XAI_OAUTH_BASE_URL,
PROVIDER_REGISTRY,
)
from hermes_cli.models import _PROVIDER_MODELS
status = get_xai_oauth_auth_status()
if status.get("logged_in"):
print(" xAI Grok OAuth (SuperGrok Subscription) credentials: ✓")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
print("Starting a fresh xAI OAuth login...")
print()
try:
mock_args = argparse.Namespace()
_login_xai_oauth(
mock_args,
PROVIDER_REGISTRY["xai-oauth"],
force_new_login=True,
)
except SystemExit:
print("Login cancelled or failed.")
return
except Exception as exc:
print(f"Login failed: {exc}")
return
elif choice == "3":
return
else:
print("Not logged into xAI Grok OAuth (SuperGrok Subscription). Starting login...")
print()
try:
mock_args = argparse.Namespace()
_login_xai_oauth(mock_args, PROVIDER_REGISTRY["xai-oauth"])
except SystemExit:
print("Login cancelled or failed.")
return
except Exception as exc:
print(f"Login failed: {exc}")
return
# Resolve a usable base URL. ``resolve_xai_oauth_runtime_credentials``
# only reads from the auth.json singleton — but credentials may legitimately
# live only in the pool (e.g. after ``hermes auth add xai-oauth``). Fall
# back to the default base URL in that case so the model picker still
# completes successfully instead of bailing out with
# ``Could not resolve xAI OAuth credentials``.
base_url = DEFAULT_XAI_OAUTH_BASE_URL
try:
creds = resolve_xai_oauth_runtime_credentials()
base_url = (creds.get("base_url") or "").strip().rstrip("/") or base_url
except Exception:
pass
models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or [])
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.3"))
if selected:
_save_model_choice(selected)
_update_config_for_provider("xai-oauth", base_url)
print(f"Default model set to: {selected} (via xAI Grok OAuth — SuperGrok Subscription)")
else:
print("No change.")
_DEFAULT_QWEN_PORTAL_MODELS = [
"qwen3-coder-plus",
"qwen3-coder",
@@ -3096,6 +3184,21 @@ def _model_flow_custom(config):
else:
print(f" If /v1 should not be in the base URL, try: {suggested}")
# Prompt for API compatibility mode explicitly so codex-compatible custom
# providers don't silently fall back to chat_completions.
current_model_cfg = config.get("model")
current_api_mode = ""
if isinstance(current_model_cfg, dict):
current_api_mode = str(current_model_cfg.get("api_mode") or "").strip()
api_mode = _prompt_custom_api_mode_selection(
effective_url,
current_api_mode=current_api_mode,
)
if api_mode:
print(f" API mode: {api_mode}")
else:
print(" API mode: auto-detect")
# Select model — use probe results when available, fall back to manual input
model_name = ""
detected_models = probe.get("models") or []
@@ -3159,7 +3262,10 @@ def _model_flow_custom(config):
model["base_url"] = effective_url
if effective_key:
model["api_key"] = effective_key
model.pop("api_mode", None) # let runtime auto-detect from URL
if api_mode:
model["api_mode"] = api_mode
else:
model.pop("api_mode", None)
save_config(cfg)
deactivate_provider()
@@ -3182,7 +3288,10 @@ def _model_flow_custom(config):
_caller_model["base_url"] = effective_url
if effective_key:
_caller_model["api_key"] = effective_key
_caller_model.pop("api_mode", None)
if api_mode:
_caller_model["api_mode"] = api_mode
else:
_caller_model.pop("api_mode", None)
config["model"] = _caller_model
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
@@ -3193,9 +3302,80 @@ def _model_flow_custom(config):
model_name or "",
context_length=context_length,
name=display_name,
api_mode=api_mode,
)
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
"""Prompt for a custom provider API mode.
Returns an explicit mode string, or None to keep auto-detect behavior.
"""
from hermes_cli.runtime_provider import _detect_api_mode_for_url
detected_mode = _detect_api_mode_for_url(base_url)
normalized_current = str(current_api_mode or "").strip().lower()
default_mode = normalized_current or detected_mode or ""
mode_options = [
(
"",
"Auto-detect",
"Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
),
(
"chat_completions",
"Chat Completions",
"Use /chat/completions for standard OpenAI-compatible servers.",
),
(
"codex_responses",
"Responses / Codex",
"Use /responses for Codex-compatible tool-calling backends.",
),
(
"anthropic_messages",
"Anthropic Messages",
"Use /v1/messages for Anthropic-compatible endpoints.",
),
]
print()
print("Select API compatibility mode:")
for idx, (value, label, description) in enumerate(mode_options, 1):
markers = []
if value == detected_mode:
markers.append("detected")
if value == default_mode:
markers.append("current")
suffix = f" [{' / '.join(markers)}]" if markers else ""
print(f" {idx}. {label}{suffix}")
print(f" {description}")
try:
raw = input(
"Choice [1-4, Enter to keep current/detected]: "
).strip().lower()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
raise
if not raw:
return default_mode or None
if raw in {"1", "auto", "detect", "auto-detect"}:
return None
if raw in {"2", "chat", "chat_completions", "completions"}:
return "chat_completions"
if raw in {"3", "responses", "codex", "codex_responses"}:
return "codex_responses"
if raw in {"4", "anthropic", "anthropic_messages", "messages"}:
return "anthropic_messages"
print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.")
return None
def _auto_provider_name(base_url: str) -> str:
"""Generate a display name from a custom endpoint URL.
@@ -3231,12 +3411,12 @@ def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""):
def _save_custom_provider(
base_url, api_key="", model="", context_length=None, name=None
base_url, api_key="", model="", context_length=None, name=None, api_mode=None
):
"""Save a custom endpoint to custom_providers in config.yaml.
Deduplicates by base_url if the URL already exists, updates the
model name and context_length but doesn't add a duplicate entry.
model name, context_length, and api_mode but doesn't add a duplicate entry.
Uses *name* when provided, otherwise auto-generates from the URL.
"""
from hermes_cli.config import load_config, save_config
@@ -3262,6 +3442,13 @@ def _save_custom_provider(
models_cfg[model] = {"context_length": context_length}
entry["models"] = models_cfg
changed = True
if api_mode:
if entry.get("api_mode") != api_mode:
entry["api_mode"] = api_mode
changed = True
elif "api_mode" in entry:
entry.pop("api_mode", None)
changed = True
if changed:
cfg["custom_providers"] = providers
save_config(cfg)
@@ -3276,6 +3463,8 @@ def _save_custom_provider(
entry["api_key"] = api_key
if model:
entry["model"] = model
if api_mode:
entry["api_mode"] = api_mode
if model and context_length:
entry["models"] = {model: {"context_length": context_length}}
@@ -3729,7 +3918,7 @@ def _model_flow_named_custom(config, provider_info):
save_config(cfg)
else:
# Save model name to the custom_providers entry for next time
_save_custom_provider(base_url, config_api_key, model_name)
_save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode)
print(f"\n✅ Model set to: {model_name}")
print(f" Provider: {name} ({base_url})")
@@ -4886,6 +5075,37 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
)
if model_list:
print(f" Found {len(model_list)} model(s) from Ollama Cloud")
elif provider_id == "novita":
from hermes_cli.models import fetch_api_models
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
curated = _PROVIDER_MODELS.get(provider_id, [])
live_models = fetch_api_models(api_key_for_probe, effective_base)
if live_models:
model_list = live_models
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
else:
mdev_models: list = []
try:
from agent.models_dev import list_agentic_models
mdev_models = list_agentic_models(provider_id)
except Exception:
pass
if mdev_models:
seen = {m.lower() for m in mdev_models}
model_list = list(mdev_models)
for m in curated:
if m.lower() not in seen:
model_list.append(m)
seen.add(m.lower())
print(f" Found {len(model_list)} model(s) from models.dev registry")
else:
model_list = curated
if model_list:
print(
f' Showing {len(model_list)} curated models — use "Enter custom model name" for others.'
)
else:
curated = _PROVIDER_MODELS.get(provider_id, [])
@@ -5565,21 +5785,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if not _web_ui_build_needed(web_dir):
return True
# Console-encoding-safe print: Windows consoles default to cp1252
# (or similar) and will raise UnicodeEncodeError on arrow / check
# glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print
# in this function through _say() with errors="replace" keeps the
# build path usable on a stock `py -m hermes_cli.main web` invocation.
def _say(text: str) -> None:
try:
print(text)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
npm = shutil.which("npm")
if not npm:
if fatal:
print("Web UI frontend not built and npm is not available.")
print("Install Node.js, then run: cd apps/dashboard && npm install && npm run build")
_say("Web UI frontend not built and npm is not available.")
_say("Install Node.js, then run: cd apps/dashboard && npm install && npm run build")
return not fatal
print("→ Building web UI...")
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
"""Print captured npm output so users can see *why* a step failed.
Windows users hitting `rm -rf` / `cp -r` errors (or any other
sync-assets / Vite failure) would otherwise see only ``Web UI
build failed`` with no hint of the underlying cause, because
the npm calls run with ``capture_output=True``.
"""
for blob in (result.stdout, result.stderr):
if not blob:
continue
text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip()
if text:
_say(text)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
if r1.returncode != 0:
print(
_say(
f" {'' if fatal else ''} Web UI npm install failed"
+ ("" if fatal else " (hermes web will not be available)")
)
_relay(r1)
if fatal:
print(" Run manually: cd apps/dashboard && npm install && npm run build")
_say(" Run manually: cd apps/dashboard && npm install && npm run build")
return False
# First attempt
r2 = subprocess.run(
@@ -5614,21 +5863,20 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# A stale UI is far better than no UI for non-interactive callers
# (Windows Scheduled Tasks, CI) — issue #23817.
if dist_index.exists():
print(" ⚠ Web UI build failed — serving stale dist as fallback")
_say(" ⚠ Web UI build failed — serving stale dist as fallback")
if stderr_tail:
print(f" Build error:\n {stderr_tail}")
_say(f" Build error:\n {stderr_tail}")
return True
print(
_say(
f" {'' if fatal else ''} Web UI build failed"
+ ("" if fatal else " (hermes web will not be available)")
)
if stderr_tail:
print(f" Build error:\n {stderr_tail}")
_relay(r2)
if fatal:
print(" Run manually: cd apps/dashboard && npm install && npm run build")
_say(" Run manually: cd apps/dashboard && npm install && npm run build")
return False
print(" ✓ Web UI built")
_say(" ✓ Web UI built")
return True
@@ -6722,6 +6970,74 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
pass
def _refresh_active_lazy_features() -> None:
"""Refresh lazy-installed backends after a code update.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
optional backends moved to ``tools/lazy_deps.py`` and only install on
first use. ``hermes update`` runs ``uv pip install -e .[all]`` which
leaves those packages untouched so if we bump a pin in
:data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already
activated the backend keep the stale version forever.
This function asks lazy_deps which features the user has previously
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet no churn for cold backends.
Never raises. A failure here must not block the rest of the update.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
if not active:
return
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
try:
results = lazy_deps.refresh_active_features(prompt=False)
except Exception as exc:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
return
refreshed = [f for f, s in results.items() if s == "refreshed"]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
if refreshed:
print(f"{len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f"{len(current)} already current")
if skipped:
# Most common reason: security.allow_lazy_installs=false. Show one
# line so the user knows why; not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if failed:
for feature, status in failed:
reason = status.split(": ", 1)[-1]
# Clip noisy pip stderr to keep update output legible.
if len(reason) > 200:
reason = reason[:200] + "..."
print(f"{feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
@@ -7648,6 +7964,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
_refresh_active_lazy_features()
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "apps" / "dashboard")
@@ -9196,10 +9514,10 @@ def _build_provider_choices() -> list[str]:
except Exception:
# Fallback: static list guarantees the CLI always works
return [
"auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot",
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
"stepfun", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee",
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
]
@@ -9219,10 +9537,10 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"computer-use",
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "mcp", "memory", "model",
"pairing", "plugins", "profile", "sessions", "setup", "skills",
"slack", "status", "tools", "uninstall", "update", "version",
"webhook", "whatsapp", "chat",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
"model", "pairing", "plugins", "profile", "proxy", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat",
# Help-ish invocations — plugin commands not being listed in
# top-level --help is an acceptable trade-off for skipping an
# expensive eager import of every bundled plugin module.
@@ -9562,6 +9880,51 @@ def main():
help="Skip the confirmation prompt",
)
# =========================================================================
# proxy command — local OpenAI-compatible proxy that attaches the user's
# OAuth-authenticated provider credentials to outbound requests. Lets
# external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in
# subscription without copy-pasting static API keys.
# =========================================================================
proxy_parser = subparsers.add_parser(
"proxy",
help="Local OpenAI-compatible proxy to OAuth providers",
description=(
"Run a local HTTP server that forwards OpenAI-compatible requests "
"to an OAuth-authenticated provider (e.g. Nous Portal). External "
"apps can point at the proxy with any bearer token; the proxy "
"attaches your real credentials."
),
)
proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command")
proxy_start = proxy_subparsers.add_parser(
"start", help="Run the proxy in the foreground"
)
proxy_start.add_argument(
"--provider",
default="nous",
help="Upstream provider (default: nous). See `hermes proxy providers`.",
)
proxy_start.add_argument(
"--host",
default=None,
help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.",
)
proxy_start.add_argument(
"--port",
type=int,
default=None,
help="Bind port (default: 8645)",
)
proxy_subparsers.add_parser(
"status", help="Show which proxy upstreams are ready"
)
proxy_subparsers.add_parser(
"providers", help="List available proxy upstream providers"
)
proxy_parser.set_defaults(func=cmd_proxy)
gateway_parser.set_defaults(func=cmd_gateway)
# =========================================================================
@@ -9682,7 +10045,7 @@ def main():
)
login_parser.add_argument(
"--provider",
choices=["nous", "openai-codex"],
choices=["nous", "openai-codex", "xai-oauth"],
default=None,
help="Provider to authenticate with (default: nous)",
)
@@ -9728,7 +10091,7 @@ def main():
)
logout_parser.add_argument(
"--provider",
choices=["nous", "openai-codex", "spotify"],
choices=["nous", "openai-codex", "xai-oauth", "spotify"],
default=None,
help="Provider to log out from (default: active provider)",
)
@@ -11450,16 +11813,57 @@ Examples:
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
)
_add_accept_hooks_flag(acp_parser)
acp_parser.add_argument(
"--version",
action="store_true",
dest="acp_version",
help="Print Hermes ACP version and exit",
)
acp_parser.add_argument(
"--check",
action="store_true",
help="Verify ACP dependencies and adapter imports, then exit",
)
acp_parser.add_argument(
"--setup",
action="store_true",
help="Run interactive Hermes provider/model setup for ACP terminal auth",
)
acp_parser.add_argument(
"--setup-browser",
action="store_true",
help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ "
"for browser tool support (idempotent).",
)
acp_parser.add_argument(
"--yes",
"-y",
action="store_true",
dest="assume_yes",
help="Accept all prompts (used by --setup-browser to skip the "
"~400 MB Chromium download confirmation).",
)
def cmd_acp(args):
"""Launch Hermes Agent as an ACP server."""
try:
from acp_adapter.entry import main as acp_main
acp_main()
acp_argv = []
if getattr(args, "acp_version", False):
acp_argv.append("--version")
if getattr(args, "check", False):
acp_argv.append("--check")
if getattr(args, "setup", False):
acp_argv.append("--setup")
if getattr(args, "setup_browser", False):
acp_argv.append("--setup-browser")
if getattr(args, "assume_yes", False):
acp_argv.append("--yes")
acp_main(acp_argv)
except ImportError:
print("ACP dependencies not installed.")
print("Install them with: pip install -e '.[acp]'")
print("ACP dependencies not installed.", file=sys.stderr)
print("Install them with: pip install -e '.[acp]'", file=sys.stderr)
sys.exit(1)
acp_parser.set_defaults(func=cmd_acp)
+2 -8
View File
@@ -25,6 +25,7 @@ from hermes_cli.config import (
)
from hermes_cli.colors import Colors, color
from hermes_constants import display_hermes_home
from tools.mcp_tool import _ENV_VAR_PATTERN
logger = logging.getLogger(__name__)
@@ -551,7 +552,7 @@ def cmd_mcp_test(args):
for k, v in headers.items():
if isinstance(v, str) and ("key" in k.lower() or "auth" in k.lower()):
# Mask the value
resolved = _interpolate_value(v)
resolved = _ENV_VAR_PATTERN.sub(lambda m: os.getenv(m.group(1), ""), v)
if len(resolved) > 8:
masked = resolved[:4] + "***" + resolved[-4:]
else:
@@ -581,13 +582,6 @@ def cmd_mcp_test(args):
print()
def _interpolate_value(value: str) -> str:
"""Resolve ``${ENV_VAR}`` references in a string."""
def _replace(m):
return os.getenv(m.group(1), "")
return re.sub(r"\$\{(\w+)\}", _replace, value)
# ─── hermes mcp login ────────────────────────────────────────────────────────
def cmd_mcp_login(args):
+8 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import getpass
import os
import sys
import shlex
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -134,7 +135,7 @@ def _install_dependencies(provider_name: str) -> None:
if check_cmd:
try:
subprocess.run(
check_cmd, shell=True, capture_output=True, timeout=5
shlex.split(check_cmd), check=True, capture_output=True, timeout=5
)
except Exception:
if install_cmd:
@@ -378,6 +379,12 @@ def _write_env_vars(env_path: Path, env_writes: dict) -> None:
new_lines.append(f"{key}={val}")
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
# Restrict permissions — .env holds API keys and tokens.
try:
import stat
env_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
except OSError:
pass # Windows or read-only FS
# ---------------------------------------------------------------------------
+110 -20
View File
@@ -116,13 +116,23 @@ def _codex_curated_models() -> list[str]:
# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).
_XAI_STATIC_FALLBACK: list[str] = [
"grok-4.3",
"grok-4.20-0309-reasoning",
"grok-4.20-0309-non-reasoning",
"grok-4.20-multi-agent-0309",
"grok-4.3",
]
_XAI_TOP_MODEL = "grok-4.3"
def _xai_promote_top(ids: list[str]) -> list[str]:
"""Pin the headline xAI model to the top of the curated list."""
if _XAI_TOP_MODEL in ids:
return [_XAI_TOP_MODEL] + [m for m in ids if m != _XAI_TOP_MODEL]
return ids
def _xai_curated_models() -> list[str]:
"""Derive the xAI-direct curated list from models.dev disk cache.
@@ -142,7 +152,7 @@ def _xai_curated_models() -> list[str]:
if isinstance(models, dict) and models:
ids = [mid for mid in models.keys() if isinstance(mid, str)]
if ids:
return sorted(ids)
return _xai_promote_top(sorted(ids))
except Exception:
# Any failure (missing file, malformed JSON, import error)
# falls through to the static list.
@@ -190,6 +200,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gpt-4o-mini",
],
"openai-codex": _codex_curated_models(),
"xai-oauth": _xai_curated_models(),
"copilot-acp": [
"copilot-acp",
],
@@ -445,6 +456,14 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# Azure Foundry: user-provided endpoint and model.
# Empty list because models depend on the endpoint configuration.
"azure-foundry": [],
"novita": [
"moonshotai/kimi-k2.5",
"minimax/minimax-m2.7",
"zai-org/glm-5",
"deepseek/deepseek-v3-0324",
"deepseek/deepseek-r1-0528",
"qwen/qwen3-235b-a22b-fp8",
],
}
# Vercel AI Gateway: derive the bare-model-id catalog from the curated
@@ -905,10 +924,12 @@ class ProviderEntry(NamedTuple):
CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
ProviderEntry("novita", "NovitaAI", "NovitaAI (AI-native cloud: Model API, Agent Sandbox, GPU Cloud)"),
ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"),
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"),
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok Subscription)", "xAI Grok OAuth (SuperGrok Subscription)"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
@@ -1014,6 +1035,8 @@ _PROVIDER_ALIASES = {
"hf": "huggingface",
"hugging-face": "huggingface",
"huggingface-hub": "huggingface",
"novita-ai": "novita",
"novitaai": "novita",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub",
@@ -1025,6 +1048,10 @@ _PROVIDER_ALIASES = {
"amazon-bedrock": "bedrock",
"amazon": "bedrock",
"grok": "xai",
"grok-oauth": "xai-oauth",
"xai-oauth": "xai-oauth",
"x-ai-oauth": "xai-oauth",
"xai-grok-oauth": "xai-oauth",
"x-ai": "xai",
"x.ai": "xai",
"nim": "nvidia",
@@ -1494,7 +1521,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
"""Return live pricing for providers that support it (openrouter, nous, ai-gateway)."""
"""Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita)."""
normalized = normalize_provider(provider)
if normalized == "openrouter":
return fetch_models_with_pricing(
@@ -1504,6 +1531,8 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d
)
if normalized == "ai-gateway":
return fetch_ai_gateway_pricing(force_refresh=force_refresh)
if normalized == "novita":
return _fetch_novita_pricing(force_refresh=force_refresh)
if normalized == "nous":
api_key, base_url = _resolve_nous_pricing_credentials()
if base_url:
@@ -1520,6 +1549,65 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d
return {}
def _fetch_novita_pricing(
timeout: float = 8.0,
*,
force_refresh: bool = False,
) -> dict[str, dict[str, str]]:
"""Fetch pricing from NovitaAI /v1/models.
NovitaAI returns input/output prices per million tokens in units of
0.0001 USD. Convert them to the per-token strings used by the shared
pricing formatter.
Results are cached in ``_pricing_cache`` keyed on the resolved base URL,
matching the pattern used by ``fetch_ai_gateway_pricing`` without this,
every menu render or pricing lookup re-hits the network.
"""
api_key = os.getenv("NOVITA_API_KEY", "").strip()
if not api_key:
return {}
base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1"
cache_key = base_url.rstrip("/")
if not force_refresh and cache_key in _pricing_cache:
return _pricing_cache[cache_key]
url = cache_key + "/models"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": _HERMES_USER_AGENT,
}
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except Exception:
_pricing_cache[cache_key] = {}
return {}
result: dict[str, dict[str, str]] = {}
for item in payload.get("data", []):
if not isinstance(item, dict):
continue
mid = item.get("id")
if not mid:
continue
inp = item.get("input_token_price_per_m")
out = item.get("output_token_price_per_m")
if inp is None and out is None:
continue
result[str(mid)] = {
"prompt": str(float(inp or 0) / 10_000 / 1_000_000),
"completion": str(float(out or 0) / 10_000 / 1_000_000),
}
_pricing_cache[cache_key] = result
return result
# All provider IDs and aliases that are valid for the provider:model syntax.
_KNOWN_PROVIDER_NAMES: set[str] = (
set(_PROVIDER_LABELS.keys())
@@ -2094,6 +2182,8 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
except Exception:
access_token = None
return get_codex_model_ids(access_token=access_token)
if normalized == "xai-oauth":
return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", [])))
if normalized in {"copilot", "copilot-acp"}:
try:
live = _fetch_github_models(_resolve_copilot_catalog_api_key())
@@ -3372,14 +3462,14 @@ def validate_requested_model(
"message": message,
}
# OpenAI Codex has its own catalog path; /v1/models probing is not the right validation path.
if normalized == "openai-codex":
# Providers with non-standard catalog validation — /v1/models probing is not the right path.
if normalized in {"openai-codex", "xai-oauth"}:
try:
codex_models = provider_model_ids("openai-codex")
catalog_models = provider_model_ids(normalized)
except Exception:
codex_models = []
if codex_models:
if requested_for_lookup in set(codex_models):
catalog_models = []
if catalog_models:
if requested_for_lookup in set(catalog_models):
return {
"accepted": True,
"persist": True,
@@ -3387,7 +3477,7 @@ def validate_requested_model(
"message": None,
}
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, codex_models, n=1, cutoff=0.9)
auto = get_close_matches(requested_for_lookup, catalog_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
@@ -3396,17 +3486,18 @@ def validate_requested_model(
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5)
suggestions = get_close_matches(requested_for_lookup, catalog_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok Subscription)"
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
"It may still work if your ChatGPT/Codex account has access to a newer or hidden model ID."
f"Note: `{requested}` was not found in the {provider_label} model listing. "
"It may still work if your account has access to a newer or hidden model ID."
f"{suggestion_text}"
),
}
@@ -3630,13 +3721,12 @@ def validate_requested_model(
# Static-catalog fallback: when the /models probe was unreachable,
# validate against the curated list from provider_model_ids() — same
# pattern as the openai-codex and minimax branches above. This fixes
# /model switches in the gateway for providers like opencode-go and
# opencode-zen whose /models endpoint returns 404 against the HTML
# marketing site. Without this block, validate_requested_model would
# reject every model on such providers, switch_model() would return
# success=False, and the gateway would never write to
# _session_model_overrides.
# pattern as the openai-codex and minimax branches above. This keeps
# /model switches working in the gateway for providers whose /models
# endpoint is temporarily unreachable or returns a non-JSON payload.
# Without this block, validate_requested_model would reject every model
# on such providers, switch_model() would return success=False, and
# the gateway would never write to _session_model_overrides.
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
try:
catalog_models = provider_model_ids(normalized)
+75
View File
@@ -542,6 +542,61 @@ class PluginContext:
self.manifest.name, provider.name,
)
# -- video gen provider registration -------------------------------------
def register_video_gen_provider(self, provider) -> None:
"""Register a video generation backend.
``provider`` must be an instance of
:class:`agent.video_gen_provider.VideoGenProvider`. The
``provider.name`` attribute is what ``video_gen.provider`` in
``config.yaml`` matches against when routing ``video_generate``
tool calls.
"""
from agent.video_gen_provider import VideoGenProvider
from agent.video_gen_registry import register_provider as _register_video_provider
if not isinstance(provider, VideoGenProvider):
logger.warning(
"Plugin '%s' tried to register a video_gen provider that does "
"not inherit from VideoGenProvider. Ignoring.",
self.manifest.name,
)
return
_register_video_provider(provider)
logger.info(
"Plugin '%s' registered video_gen provider: %s",
self.manifest.name, provider.name,
)
# -- web search/extract provider registration ----------------------------
def register_web_search_provider(self, provider) -> None:
"""Register a web search/extract backend.
``provider`` must be an instance of
:class:`agent.web_search_provider.WebSearchProvider`. The
``provider.name`` attribute is what ``web.search_backend`` /
``web.extract_backend`` / ``web.backend`` in ``config.yaml``
matches against when routing ``web_search`` / ``web_extract``
tool calls.
"""
from agent.web_search_provider import WebSearchProvider
from agent.web_search_registry import register_provider as _register_web_provider
if not isinstance(provider, WebSearchProvider):
logger.warning(
"Plugin '%s' tried to register a web provider that does "
"not inherit from WebSearchProvider. Ignoring.",
self.manifest.name,
)
return
_register_web_provider(provider)
logger.info(
"Plugin '%s' registered web provider: %s",
self.manifest.name, provider.name,
)
# -- platform adapter registration ---------------------------------------
def register_platform(
@@ -1312,6 +1367,21 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
_thread_tool_whitelist = threading.local()
def set_thread_tool_whitelist(
allowed: Optional[Set[str]],
deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist",
) -> None:
_thread_tool_whitelist.allowed = allowed
_thread_tool_whitelist.fmt = deny_msg_fmt
def clear_thread_tool_whitelist() -> None:
_thread_tool_whitelist.allowed = None
def get_pre_tool_call_block_message(
tool_name: str,
args: Optional[Dict[str, Any]],
@@ -1330,6 +1400,11 @@ def get_pre_tool_call_block_message(
directive wins. Invalid or irrelevant hook return values are
silently ignored so existing observer-only hooks are unaffected.
"""
allowed = getattr(_thread_tool_whitelist, "allowed", None)
if allowed is not None and tool_name not in allowed:
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
return fmt.format(tool_name=tool_name)
hook_results = invoke_hook(
"pre_tool_call",
tool_name=tool_name,
-85
View File
@@ -1295,91 +1295,6 @@ def rename_profile(old_name: str, new_name: str) -> Path:
return new_dir
# ---------------------------------------------------------------------------
# Tab completion
# ---------------------------------------------------------------------------
def generate_bash_completion() -> str:
"""Generate a bash completion script for hermes profile names."""
return '''# Hermes Agent profile completion
# Add to ~/.bashrc: eval "$(hermes completion bash)"
_hermes_profiles() {
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
fi
echo "$profiles"
}
_hermes_completion() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Complete profile names after -p / --profile
if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
return
fi
# Complete profile subcommands
if [[ "${COMP_WORDS[1]}" == "profile" ]]; then
case "$prev" in
profile)
COMPREPLY=($(compgen -W "list use create delete show alias rename export import" -- "$cur"))
return
;;
use|delete|show|alias|rename|export)
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
return
;;
esac
fi
# Top-level subcommands
if [[ "$COMP_CWORD" == 1 ]]; then
local commands="chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version"
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
fi
}
complete -F _hermes_completion hermes
'''
def generate_zsh_completion() -> str:
"""Generate a zsh completion script for hermes profile names."""
return '''#compdef hermes
# Hermes Agent profile completion
# Add to ~/.zshrc: eval "$(hermes completion zsh)"
_hermes() {
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=("${(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}")
fi
_arguments \\
'-p[Profile name]:profile:($profiles)' \\
'--profile[Profile name]:profile:($profiles)' \\
'1:command:(chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version)' \\
'*::arg:->args'
case $words[1] in
profile)
_arguments '1:action:(list use create delete show alias rename export import)' \\
'2:profile:($profiles)'
;;
esac
}
_hermes "$@"
'''
# ---------------------------------------------------------------------------
# Profile env resolution (called from _apply_profile_override)
# ---------------------------------------------------------------------------
+19
View File
@@ -60,6 +60,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
auth_type="oauth_external",
base_url_override="https://chatgpt.com/backend-api/codex",
),
"xai-oauth": HermesOverlay(
transport="codex_responses",
auth_type="oauth_external",
base_url_override="https://api.x.ai/v1",
base_url_env_var="XAI_BASE_URL",
),
"qwen-oauth": HermesOverlay(
transport="openai_chat",
auth_type="oauth_external",
@@ -156,6 +162,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
is_aggregator=True,
base_url_env_var="HF_BASE_URL",
),
"novita": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
base_url_env_var="NOVITA_BASE_URL",
),
"xai": HermesOverlay(
transport="codex_responses",
base_url_override="https://api.x.ai/v1",
@@ -239,6 +250,10 @@ ALIASES: Dict[str, str] = {
"x-ai": "xai",
"x.ai": "xai",
"grok": "xai",
"grok-oauth": "xai-oauth",
"xai-oauth": "xai-oauth",
"x-ai-oauth": "xai-oauth",
"xai-grok-oauth": "xai-oauth",
# nvidia
"nim": "nvidia",
@@ -309,6 +324,10 @@ ALIASES: Dict[str, str] = {
"hugging-face": "huggingface",
"huggingface-hub": "huggingface",
# novita
"novita-ai": "novita",
"novitaai": "novita",
# xiaomi
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
+20
View File
@@ -0,0 +1,20 @@
"""Local OpenAI-compatible proxy that forwards to OAuth-authenticated upstreams.
Lets external apps (OpenViking, Karakeep, Open WebUI, ...) ride the user's
already-logged-in provider subscription instead of needing a static API key
copy-pasted into each app's config.
The proxy listens on ``127.0.0.1:<port>``, accepts any bearer (the client's
``Authorization`` header is discarded), and attaches the user's real
upstream credential to the forwarded request. The credential is refreshed
automatically when it approaches expiry.
First-class adapter:
- ``nous`` Nous Portal (https://inference-api.nousresearch.com/v1)
Future adapters can plug in by implementing ``UpstreamAdapter``.
"""
from hermes_cli.proxy.adapters.base import UpstreamAdapter
__all__ = ["UpstreamAdapter"]
+35
View File
@@ -0,0 +1,35 @@
"""Upstream adapter registry for the local proxy server.
Each adapter wraps a provider's OAuth state and exposes a uniform interface
the proxy server can use to forward requests with a freshly-minted bearer
token. See :class:`UpstreamAdapter` for the contract.
"""
from typing import Dict, Type
from hermes_cli.proxy.adapters.base import UpstreamAdapter
from hermes_cli.proxy.adapters.nous_portal import NousPortalAdapter
# Registry of available adapter classes keyed by provider name as used on
# the ``hermes proxy start --provider <name>`` CLI flag.
ADAPTERS: Dict[str, Type[UpstreamAdapter]] = {
"nous": NousPortalAdapter,
}
def get_adapter(name: str) -> UpstreamAdapter:
"""Instantiate an adapter by provider name.
Raises:
ValueError: if ``name`` is not a registered adapter.
"""
key = (name or "").strip().lower()
if key not in ADAPTERS:
available = ", ".join(sorted(ADAPTERS)) or "(none)"
raise ValueError(
f"Unknown proxy upstream provider: {name!r}. Available: {available}"
)
return ADAPTERS[key]()
__all__ = ["UpstreamAdapter", "ADAPTERS", "get_adapter"]
+94
View File
@@ -0,0 +1,94 @@
"""Abstract base for proxy upstream adapters.
An :class:`UpstreamAdapter` represents one OAuth-authenticated provider the
local proxy can forward requests to. The adapter is responsible for:
- locating the user's auth state for that provider
- refreshing/minting credentials when needed
- reporting the resolved upstream base URL
- declaring which request paths it accepts
The proxy server is otherwise provider-agnostic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import FrozenSet, Optional
@dataclass(frozen=True)
class UpstreamCredential:
"""A resolved bearer + base URL ready to forward to."""
bearer: str
"""Authorization header value to send upstream (token only, no ``Bearer`` prefix)."""
base_url: str
"""Upstream base URL, e.g. ``https://inference-api.nousresearch.com/v1``."""
token_type: str = "Bearer"
"""Auth scheme — currently always ``Bearer`` for supported providers."""
expires_at: Optional[str] = None
"""ISO-8601 expiry timestamp for the bearer, when known. Informational."""
class UpstreamAdapter(ABC):
"""Contract for an upstream provider the proxy can forward to."""
@property
@abstractmethod
def name(self) -> str:
"""Adapter key used on the CLI (e.g. ``"nous"``)."""
@property
@abstractmethod
def display_name(self) -> str:
"""Human-readable provider name for logs and ``proxy status``."""
@property
@abstractmethod
def allowed_paths(self) -> FrozenSet[str]:
"""Set of relative request paths the upstream accepts.
Paths are relative to the proxy's ``/v1`` mount point. For example,
``"/chat/completions"`` corresponds to a client request to
``http://127.0.0.1:<port>/v1/chat/completions``. Requests to paths
not in this set get a 404 with a helpful error body.
"""
@abstractmethod
def is_authenticated(self) -> bool:
"""Return True if the user has usable credentials for this upstream.
Should be cheap no network calls. Used by ``proxy start`` for a
clear up-front error before binding a port.
"""
@abstractmethod
def get_credential(self) -> UpstreamCredential:
"""Return a fresh credential, refreshing/minting if necessary.
Implementations should:
- refresh the access token if it's near expiry
- mint/rotate the upstream bearer key if it's near expiry
- persist any refreshed state back to disk
Raises:
RuntimeError: if the user isn't authenticated or the upstream
refresh fails. The proxy will return 401 to the client.
"""
def describe(self) -> str:
"""One-line status summary for ``proxy status``."""
try:
cred = self.get_credential()
except Exception as exc: # pragma: no cover - defensive
return f"{self.display_name}: not ready ({exc})"
ttl = f" (expires {cred.expires_at})" if cred.expires_at else ""
return f"{self.display_name}: {cred.base_url}{ttl}"
__all__ = ["UpstreamAdapter", "UpstreamCredential"]
+137
View File
@@ -0,0 +1,137 @@
"""Nous Portal upstream adapter.
Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes
the access token and mints a fresh agent key when needed, and exposes the
upstream base URL plus minted bearer for the proxy server to forward to.
The minted ``agent_key`` (not the OAuth ``access_token``) is what
``inference-api.nousresearch.com`` accepts as a bearer. The refresh helper
already handles both see :func:`hermes_cli.auth.refresh_nous_oauth_from_state`.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict, FrozenSet, Optional
from hermes_cli.auth import (
DEFAULT_NOUS_INFERENCE_URL,
_load_auth_store,
_save_auth_store,
_write_shared_nous_state,
refresh_nous_oauth_from_state,
)
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
logger = logging.getLogger(__name__)
# Endpoints inference-api.nousresearch.com actually serves. Anything else
# the proxy will reject with 404 — keeps stray clients from leaking weird
# requests to the upstream.
_ALLOWED_PATHS: FrozenSet[str] = frozenset(
{
"/chat/completions",
"/completions",
"/embeddings",
"/models",
}
)
class NousPortalAdapter(UpstreamAdapter):
"""Proxy upstream for the Nous Portal inference API."""
def __init__(self) -> None:
# Lock guards _load → refresh → _save against parallel proxy requests
# racing to refresh expired tokens. Refresh itself is HTTP, so we
# hold the lock across the network call (brief; OAuth refresh is fast).
self._lock = threading.Lock()
@property
def name(self) -> str:
return "nous"
@property
def display_name(self) -> str:
return "Nous Portal"
@property
def allowed_paths(self) -> FrozenSet[str]:
return _ALLOWED_PATHS
def is_authenticated(self) -> bool:
state = self._read_state()
if state is None:
return False
# We need either a usable agent_key OR (refresh_token + access_token)
# to recover. The refresh helper will mint/refresh as needed.
return bool(
state.get("agent_key")
or (state.get("refresh_token") and state.get("access_token"))
)
def get_credential(self) -> UpstreamCredential:
with self._lock:
state = self._read_state()
if state is None:
raise RuntimeError(
"Not logged into Nous Portal. Run `hermes login nous` first."
)
try:
refreshed = refresh_nous_oauth_from_state(state)
except Exception as exc:
raise RuntimeError(
f"Failed to refresh Nous Portal credentials: {exc}"
) from exc
self._save_state(refreshed)
agent_key = refreshed.get("agent_key")
if not agent_key:
raise RuntimeError(
"Nous Portal refresh did not return a usable agent_key. "
"Try `hermes login nous` to re-authenticate."
)
base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL
base_url = base_url.rstrip("/")
return UpstreamCredential(
bearer=agent_key,
base_url=base_url,
expires_at=refreshed.get("agent_key_expires_at"),
)
# ------------------------------------------------------------------
# Internal helpers — auth.json access. Kept local rather than added
# to hermes_cli.auth to avoid expanding that module's public surface.
# ------------------------------------------------------------------
def _read_state(self) -> Optional[Dict[str, Any]]:
try:
store = _load_auth_store()
except Exception as exc:
logger.warning("proxy: failed to load auth store: %s", exc)
return None
providers = store.get("providers") or {}
state = providers.get("nous")
if not isinstance(state, dict):
return None
return dict(state) # copy so the refresh helper can mutate freely
def _save_state(self, state: Dict[str, Any]) -> None:
try:
store = _load_auth_store()
providers = store.setdefault("providers", {})
providers["nous"] = state
_save_auth_store(store)
_write_shared_nous_state(state)
except Exception as exc:
# Best effort — we still return the fresh credential. The next
# request just won't see cached state, which means another refresh.
logger.warning("proxy: failed to persist refreshed Nous state: %s", exc)
__all__ = ["NousPortalAdapter"]
+141
View File
@@ -0,0 +1,141 @@
"""CLI handlers for the ``hermes proxy`` subcommand."""
from __future__ import annotations
import asyncio
import logging
import sys
from typing import Any
from hermes_cli.proxy.adapters import ADAPTERS, get_adapter
from hermes_cli.proxy.server import (
AIOHTTP_AVAILABLE,
DEFAULT_HOST,
DEFAULT_PORT,
run_server,
)
logger = logging.getLogger(__name__)
def _print_aiohttp_missing() -> None:
print(
"hermes proxy requires aiohttp. Install one of:\n"
" pip install 'hermes-agent[messaging]'\n"
" pip install aiohttp",
file=sys.stderr,
)
def cmd_proxy_start(args: Any) -> int:
"""Run the proxy server in the foreground.
Returns process exit code (0 on clean shutdown).
"""
if not AIOHTTP_AVAILABLE:
_print_aiohttp_missing()
return 1
provider = getattr(args, "provider", None) or "nous"
try:
adapter = get_adapter(provider)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
if not adapter.is_authenticated():
print(
f"Not logged into {adapter.display_name}. "
f"Run `hermes login {adapter.name}` first.",
file=sys.stderr,
)
return 2
host = getattr(args, "host", None) or DEFAULT_HOST
port = getattr(args, "port", None) or DEFAULT_PORT
print(
f"Starting Hermes proxy for {adapter.display_name}\n"
f" Listening on: http://{host}:{port}/v1\n"
f" Forwarding to: (resolved per-request from your subscription)\n"
f" Use any bearer token in the client — the proxy attaches your real credential.\n"
f"\n"
f"Press Ctrl+C to stop.",
file=sys.stderr,
)
try:
asyncio.run(run_server(adapter, host=host, port=port))
except KeyboardInterrupt:
print("\nproxy: stopped", file=sys.stderr)
except OSError as exc:
print(f"proxy: failed to bind {host}:{port}: {exc}", file=sys.stderr)
return 1
return 0
def cmd_proxy_status(args: Any) -> int:
"""Print the status of each configured upstream adapter."""
print("Hermes proxy upstream adapters\n")
for name in sorted(ADAPTERS):
adapter = get_adapter(name)
if not adapter.is_authenticated():
print(f" [{name:8s}] {adapter.display_name} — not logged in")
continue
try:
cred = adapter.get_credential()
except Exception as exc:
print(
f" [{name:8s}] {adapter.display_name} — credentials need attention "
f"({exc})"
)
continue
expires = f" (bearer expires {cred.expires_at})" if cred.expires_at else ""
print(f" [{name:8s}] {adapter.display_name} — ready{expires}")
print(
"\nStart the proxy with: hermes proxy start [--provider <name>]"
)
return 0
def cmd_proxy_list_providers(args: Any) -> int:
"""List available proxy upstream providers."""
print("Available proxy upstream providers:")
for name in sorted(ADAPTERS):
adapter = get_adapter(name)
print(f" {name}{adapter.display_name}")
return 0
def cmd_proxy(args: Any) -> int:
"""Dispatch ``hermes proxy <subcommand>``."""
sub = getattr(args, "proxy_command", None)
if sub == "start":
return cmd_proxy_start(args)
if sub == "status":
return cmd_proxy_status(args)
if sub in ("providers", "list"):
return cmd_proxy_list_providers(args)
# No subcommand → print short help.
print(
"hermes proxy — local OpenAI-compatible proxy that attaches your\n"
"OAuth-authenticated provider credentials to outbound requests.\n"
"\n"
"Subcommands:\n"
" hermes proxy start [--provider nous] [--host 127.0.0.1] [--port 8645]\n"
" Run the proxy in the foreground.\n"
" hermes proxy status\n"
" Show which upstream adapters are ready.\n"
" hermes proxy providers\n"
" List available upstream providers.\n",
file=sys.stderr,
)
return 0
__all__ = [
"cmd_proxy",
"cmd_proxy_start",
"cmd_proxy_status",
"cmd_proxy_list_providers",
]
+265
View File
@@ -0,0 +1,265 @@
"""HTTP server that forwards OpenAI-compatible requests to a configured upstream.
Listens on ``http://<host>:<port>/v1/<path>`` and forwards each request to
``<upstream-base-url>/<path>`` with the client's ``Authorization`` header
replaced by a freshly-resolved bearer from the configured adapter. The
response is streamed back unmodified, preserving SSE.
The server is intentionally minimal: it does NOT mediate, log, transform,
or rewrite request/response bodies. It's a credential-attaching forwarder.
"""
from __future__ import annotations
import asyncio
import json
import logging
import signal
from typing import Optional
try:
import aiohttp
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
aiohttp = None # type: ignore[assignment]
web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
from hermes_cli.proxy.adapters.base import UpstreamAdapter
logger = logging.getLogger(__name__)
# Headers we strip when forwarding to the upstream. ``host``/``content-length``
# are recomputed by aiohttp; ``authorization`` is replaced with our bearer.
# Everything else (content-type, accept, user-agent, x-* headers) passes through.
_HOP_BY_HOP_HEADERS = frozenset(
{
"host",
"content-length",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"authorization", # we replace this one
}
)
DEFAULT_PORT = 8645
DEFAULT_HOST = "127.0.0.1"
def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response":
"""Return an OpenAI-style error JSON response."""
body = {"error": {"message": message, "type": code, "code": code}}
return web.json_response(body, status=status)
def _filter_request_headers(headers: "aiohttp.typedefs.LooseHeaders") -> dict:
"""Strip hop-by-hop + auth headers from the inbound request."""
out = {}
for key, value in headers.items():
if key.lower() in _HOP_BY_HOP_HEADERS:
continue
out[key] = value
return out
def _filter_response_headers(headers) -> dict:
"""Strip hop-by-hop headers from the upstream response."""
out = {}
for key, value in headers.items():
if key.lower() in _HOP_BY_HOP_HEADERS:
continue
# aiohttp recomputes Content-Encoding/Content-Length on stream — let it.
if key.lower() in ("content-encoding", "content-length"):
continue
out[key] = value
return out
def create_app(adapter: UpstreamAdapter) -> "web.Application":
"""Build the aiohttp application bound to a specific upstream adapter."""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
)
app = web.Application()
# AppKey ensures forward-compat with future aiohttp versions that strip
# bare-string keys.
_adapter_key = web.AppKey("adapter", UpstreamAdapter)
app[_adapter_key] = adapter
async def handle_health(request: "web.Request") -> "web.Response":
return web.json_response(
{
"status": "ok",
"upstream": adapter.display_name,
"authenticated": adapter.is_authenticated(),
}
)
async def handle_models_fallback(request: "web.Request") -> "web.Response":
# Most clients hit /v1/models on startup. If the upstream doesn't
# serve /models, synthesize a minimal response so clients don't
# crash. The actual forwarding path handles /models when allowed.
return web.json_response(
{
"object": "list",
"data": [],
}
)
async def handle_proxy(request: "web.Request") -> "web.StreamResponse":
# Extract the path *after* /v1
rel_path = request.match_info.get("tail", "")
rel_path = "/" + rel_path.lstrip("/")
if rel_path not in adapter.allowed_paths:
allowed = ", ".join(sorted(adapter.allowed_paths))
return _json_error(
404,
f"Path /v1{rel_path} is not forwarded by this proxy. "
f"Allowed: {allowed}",
code="path_not_allowed",
)
try:
cred = adapter.get_credential()
except Exception as exc:
logger.warning("proxy: credential resolution failed: %s", exc)
return _json_error(401, str(exc), code="upstream_auth_failed")
upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}"
# Preserve query string verbatim.
if request.query_string:
upstream_url = f"{upstream_url}?{request.query_string}"
# Forward body verbatim. Read into memory once — request bodies for
# chat/completions/embeddings are small (<1MB typically). If we ever
# need to forward large multipart uploads we'll switch to streaming
# the request body too.
body = await request.read()
fwd_headers = _filter_request_headers(request.headers)
fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}"
logger.debug(
"proxy: forwarding %s %s -> %s (body=%d bytes)",
request.method, rel_path, upstream_url, len(body),
)
# Use a per-request session so connection state doesn't leak between
# clients. Could be optimized to a shared session later.
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300)
try:
session = aiohttp.ClientSession(timeout=timeout)
except Exception as exc: # pragma: no cover - aiohttp setup issue
return _json_error(500, f"proxy session init failed: {exc}")
try:
upstream_resp = await session.request(
request.method,
upstream_url,
data=body if body else None,
headers=fwd_headers,
allow_redirects=False,
)
except aiohttp.ClientError as exc:
await session.close()
logger.warning("proxy: upstream connection failed: %s", exc)
return _json_error(502, f"upstream connection failed: {exc}",
code="upstream_unreachable")
except asyncio.TimeoutError:
await session.close()
return _json_error(504, "upstream request timed out",
code="upstream_timeout")
# Stream response back. Headers first, then chunked body.
resp = web.StreamResponse(
status=upstream_resp.status,
headers=_filter_response_headers(upstream_resp.headers),
)
await resp.prepare(request)
try:
async for chunk in upstream_resp.content.iter_any():
if chunk:
await resp.write(chunk)
except (aiohttp.ClientError, asyncio.CancelledError) as exc:
logger.warning("proxy: streaming interrupted: %s", exc)
finally:
upstream_resp.release()
await session.close()
await resp.write_eof()
return resp
# /health doesn't go through the upstream
app.router.add_get("/health", handle_health)
# Catch-all under /v1 — forwards if the path is allowed.
app.router.add_route("*", "/v1/{tail:.*}", handle_proxy)
return app
async def run_server(
adapter: UpstreamAdapter,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
shutdown_event: Optional[asyncio.Event] = None,
) -> None:
"""Run the proxy in the current event loop until shutdown_event is set.
If shutdown_event is None, runs until cancelled (Ctrl+C or SIGTERM).
"""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
)
app = create_app(adapter)
runner = web.AppRunner(app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, host=host, port=port)
await site.start()
logger.info(
"proxy: listening on http://%s:%d/v1 -> %s",
host, port, adapter.display_name,
)
stop_event = shutdown_event or asyncio.Event()
# Wire signal handlers when we own the loop's lifetime.
if shutdown_event is None:
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok
except NotImplementedError:
# Windows / restricted environments — Ctrl+C will still
# raise KeyboardInterrupt and unwind us.
pass
try:
await stop_event.wait()
finally:
logger.info("proxy: shutting down")
await runner.cleanup()
__all__ = [
"create_app",
"run_server",
"DEFAULT_HOST",
"DEFAULT_PORT",
"AIOHTTP_AVAILABLE",
]
+71 -3
View File
@@ -15,12 +15,14 @@ from hermes_cli.auth import (
AuthError,
DEFAULT_CODEX_BASE_URL,
DEFAULT_QWEN_BASE_URL,
DEFAULT_XAI_OAUTH_BASE_URL,
PROVIDER_REGISTRY,
_agent_key_is_usable,
format_auth_error,
resolve_provider,
resolve_nous_runtime_credentials,
resolve_codex_runtime_credentials,
resolve_xai_oauth_runtime_credentials,
resolve_qwen_runtime_credentials,
resolve_gemini_oauth_runtime_credentials,
resolve_api_key_provider_credentials,
@@ -102,8 +104,10 @@ def _auto_detect_local_model(base_url: str) -> str:
model_id = models[0].get("id", "")
if model_id:
return model_id
except Exception:
pass
except Exception as exc:
# Log instead of silently swallowing — aids debugging when
# local model auto-detection fails unexpectedly.
logger.debug("Auto-detect model from %s failed: %s", base_url, exc)
return ""
@@ -164,7 +168,18 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
return "chat_completions"
_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}
_VALID_API_MODES = {
"chat_completions",
"codex_responses",
"anthropic_messages",
"bedrock_converse",
# Optional opt-in: hand the entire turn to a `codex app-server` subprocess
# so terminal/file-ops/patching/sandboxing run inside Codex's own runtime
# instead of Hermes' tool dispatch. Gated behind config key
# `model.openai_runtime == "codex_app_server"` AND provider in
# {"openai", "openai-codex"}. Default is unchanged.
"codex_app_server",
}
def _parse_api_mode(raw: Any) -> Optional[str]:
@@ -176,6 +191,32 @@ def _parse_api_mode(raw: Any) -> Optional[str]:
return None
def _maybe_apply_codex_app_server_runtime(
*,
provider: str,
api_mode: str,
model_cfg: Optional[Dict[str, Any]],
) -> str:
"""Optional opt-in: rewrite api_mode → "codex_app_server" for OpenAI/Codex
providers when the user has explicitly enabled that runtime via
`model.openai_runtime: codex_app_server` in config.yaml.
Default behavior is preserved: when the key is unset, "auto", or empty,
this function is a no-op. Only providers in {"openai", "openai-codex"}
are eligible other providers (anthropic, openrouter, etc.) cannot be
rerouted through codex.
Returns the (possibly-rewritten) api_mode."""
if not model_cfg:
return api_mode
if provider not in ("openai", "openai-codex"):
return api_mode
runtime = str(model_cfg.get("openai_runtime") or "").strip().lower()
if runtime == "codex_app_server":
return "codex_app_server"
return api_mode
def _resolve_runtime_from_pool_entry(
*,
provider: str,
@@ -199,6 +240,9 @@ def _resolve_runtime_from_pool_entry(
if provider == "openai-codex":
api_mode = "codex_responses"
base_url = base_url or DEFAULT_CODEX_BASE_URL
elif provider == "xai-oauth":
api_mode = "codex_responses"
base_url = base_url or DEFAULT_XAI_OAUTH_BASE_URL
elif provider == "qwen-oauth":
api_mode = "chat_completions"
base_url = base_url or DEFAULT_QWEN_BASE_URL
@@ -293,6 +337,12 @@ def _resolve_runtime_from_pool_entry(
if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}:
base_url = re.sub(r"/v1/?$", "", base_url)
# Optional opt-in: route OpenAI/Codex turns through `codex app-server`.
# Inert when `model.openai_runtime` is unset or "auto".
api_mode = _maybe_apply_codex_app_server_runtime(
provider=provider, api_mode=api_mode, model_cfg=model_cfg
)
return {
"provider": provider,
"api_mode": api_mode,
@@ -1087,6 +1137,24 @@ def resolve_runtime_provider(
logger.info("Auto-detected Codex provider but credentials failed; "
"falling through to next provider.")
if provider == "xai-oauth":
try:
creds = resolve_xai_oauth_runtime_credentials()
return {
"provider": "xai-oauth",
"api_mode": "codex_responses",
"base_url": (creds.get("base_url") or "").rstrip("/") or DEFAULT_XAI_OAUTH_BASE_URL,
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "hermes-auth-store"),
"last_refresh": creds.get("last_refresh"),
"requested_provider": requested_provider,
}
except AuthError:
if requested_provider != "auto":
raise
logger.info("Auto-detected xAI OAuth provider but credentials failed; "
"falling through to next provider.")
if provider == "qwen-oauth":
try:
creds = resolve_qwen_runtime_credentials()
+123 -35
View File
@@ -454,6 +454,26 @@ def _print_setup_summary(config: dict, hermes_home):
else:
tool_status.append(("Image Generation", False, "FAL_KEY or OPENAI_API_KEY"))
# Video generation — opt-in via `hermes tools` → Video Generation.
# Only show the row when a plugin reports available so we don't badger
# users who don't care about video gen with a "missing" status line.
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
# TTS — show configured provider
tts_provider = cfg_get(config, "tts", "provider", default="edge")
if subscription_features.tts.managed_by_nous:
@@ -502,14 +522,6 @@ def _print_setup_summary(config: dict, hermes_home):
elif managed_nous_tools_enabled() and subscription_features.nous_auth_present:
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
# Tinker + WandB (RL training)
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
tool_status.append(("RL Training (Tinker)", True, None))
elif get_env_value("TINKER_API_KEY"):
tool_status.append(("RL Training (Tinker)", False, "WANDB_API_KEY"))
else:
tool_status.append(("RL Training (Tinker)", False, "TINKER_API_KEY"))
# Home Assistant
if get_env_value("HASS_TOKEN"):
tool_status.append(("Smart Home (Home Assistant)", True, None))
@@ -1079,6 +1091,58 @@ def _install_kittentts_deps() -> bool:
return False
def _xai_oauth_logged_in_for_setup() -> bool:
"""True iff xAI Grok OAuth credentials are already stored locally.
Lets TTS / STT setup skip the API-key prompt for users who logged in
through ``hermes model`` -> xAI Grok OAuth (SuperGrok Subscription).
"""
try:
from hermes_cli.auth import get_xai_oauth_auth_status
return bool(get_xai_oauth_auth_status().get("logged_in"))
except Exception:
return False
def _run_xai_oauth_login_from_setup() -> bool:
"""Run the xAI Grok OAuth loopback login from inside the setup wizard.
Returns True on success, False on any failure (the caller falls back
to whatever the user picked next, e.g. Edge TTS).
"""
try:
from hermes_cli.auth import (
DEFAULT_XAI_OAUTH_BASE_URL,
_is_remote_session,
_save_xai_oauth_tokens,
_update_config_for_provider,
_xai_oauth_loopback_login,
)
except Exception as exc:
print_warning(f"xAI Grok OAuth helpers unavailable: {exc}")
return False
open_browser = not _is_remote_session()
print()
print_info("Signing in to xAI Grok OAuth (SuperGrok Subscription)...")
try:
creds = _xai_oauth_loopback_login(open_browser=open_browser)
_save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
)
_update_config_for_provider(
"xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL)
)
return True
except Exception as exc:
print_warning(f"xAI Grok OAuth login failed: {exc}")
return False
def _setup_tts_provider(config: dict):
"""Interactive TTS provider selection with install flow for NeuTTS."""
tts_config = config.get("tts", {})
@@ -1113,7 +1177,7 @@ def _setup_tts_provider(config: dict):
"Edge TTS (free, cloud-based, no setup needed)",
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"xAI TTS (Grok voices, needs API key)",
"xAI TTS (Grok voices — OAuth login or API key)",
"MiniMax TTS (high quality with voice cloning, needs API key)",
"Mistral Voxtral TTS (multilingual, native Opus, needs API key)",
"Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)",
@@ -1187,21 +1251,59 @@ def _setup_tts_provider(config: dict):
selected = "edge"
elif selected == "xai":
existing = get_env_value("XAI_API_KEY")
if not existing:
# Resolution order: existing OAuth tokens (free for SuperGrok subscribers
# via the Hermes auth store) > existing XAI_API_KEY > prompt the user.
# When neither is configured, offer both options instead of forcing the
# API-key path — xAI TTS works fine with OAuth bearer tokens too.
oauth_logged_in = _xai_oauth_logged_in_for_setup()
existing_api_key = get_env_value("XAI_API_KEY")
if oauth_logged_in:
print_success(
"xAI TTS will use your xAI Grok OAuth (SuperGrok Subscription) "
"credentials"
)
elif existing_api_key:
print_success("xAI TTS will use your existing XAI_API_KEY")
else:
print()
api_key = prompt("xAI API key for TTS", password=True)
if api_key:
save_env_value("XAI_API_KEY", api_key)
print_success("xAI TTS API key saved")
choice_idx = prompt_choice(
"How do you want xAI TTS to authenticate?",
choices=[
"Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login",
"Paste an xAI API key (console.x.ai)",
"Skip → fallback to Edge TTS",
],
default=0,
)
if choice_idx == 0:
if _run_xai_oauth_login_from_setup():
print_success(
"Logged in — xAI TTS will use these OAuth credentials"
)
else:
print_warning(
"xAI Grok OAuth login did not complete. "
"Falling back to Edge TTS."
)
selected = "edge"
elif choice_idx == 1:
api_key = prompt("xAI API key for TTS", password=True)
if api_key:
save_env_value("XAI_API_KEY", api_key)
print_success("xAI TTS API key saved")
else:
from hermes_constants import display_hermes_home as _dhh
print_warning(
"No xAI API key provided for TTS. Configure XAI_API_KEY "
f"via hermes setup model or {_dhh()}/.env to use xAI TTS. "
"Falling back to Edge TTS."
)
selected = "edge"
else:
from hermes_constants import display_hermes_home as _dhh
print_warning(
"No xAI API key provided for TTS. Configure XAI_API_KEY via "
f"hermes setup model or {_dhh()}/.env to use xAI TTS. "
"Falling back to Edge TTS."
)
print_warning("xAI TTS skipped. Falling back to Edge TTS.")
selected = "edge"
if selected == "xai":
print()
voice_id = prompt("xAI voice_id (Enter for 'eve', or paste a custom voice ID)")
@@ -3246,18 +3348,6 @@ def run_setup_wizard(args):
print_info(f" cp {_backup_path} {config_path}")
_print_setup_summary(config, hermes_home)
_offer_launch_chat()
def _offer_launch_chat():
"""Prompt the user to jump straight into chat after setup."""
print()
if not prompt_yes_no("Launch hermes chat now?", True):
return
from hermes_cli.relaunch import relaunch
relaunch(["chat"])
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
"""Streamlined first-time setup: provider, model, terminal & messaging.
@@ -3301,8 +3391,6 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
_print_setup_summary(config, hermes_home)
_offer_launch_chat()
def _run_quick_setup(config: dict, hermes_home):
"""Quick setup — only configure items that are missing."""
+37 -8
View File
@@ -666,25 +666,46 @@ def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]:
return None
def _mapping_or_empty(value: Any, *, section: str, skin_name: str) -> Dict[str, Any]:
"""Return a mapping value or an empty dict when the section type is invalid."""
if isinstance(value, dict):
return value
if value is None:
return {}
logger.warning(
"Skin '%s' has invalid '%s' section type (%s); ignoring section",
skin_name,
section,
type(value).__name__,
)
return {}
def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
"""Build a SkinConfig from a raw dict (built-in or loaded from YAML)."""
# Start with default values as base for missing keys
default = _BUILTIN_SKINS["default"]
skin_name = str(data.get("name", "unknown"))
color_overrides = _mapping_or_empty(data.get("colors"), section="colors", skin_name=skin_name)
spinner_overrides = _mapping_or_empty(data.get("spinner"), section="spinner", skin_name=skin_name)
branding_overrides = _mapping_or_empty(data.get("branding"), section="branding", skin_name=skin_name)
emoji_overrides = _mapping_or_empty(data.get("tool_emojis"), section="tool_emojis", skin_name=skin_name)
colors = dict(default.get("colors", {}))
colors.update(data.get("colors", {}))
colors.update(color_overrides)
spinner = dict(default.get("spinner", {}))
spinner.update(data.get("spinner", {}))
spinner.update(spinner_overrides)
branding = dict(default.get("branding", {}))
branding.update(data.get("branding", {}))
branding.update(branding_overrides)
return SkinConfig(
name=data.get("name", "unknown"),
name=skin_name,
description=data.get("description", ""),
colors=colors,
spinner=spinner,
branding=branding,
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "")),
tool_emojis=data.get("tool_emojis", {}),
tool_emojis=emoji_overrides,
banner_logo=data.get("banner_logo", ""),
banner_hero=data.get("banner_hero", ""),
)
@@ -828,10 +849,14 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
except Exception:
return {}
prompt = skin.get_color("prompt", "#FFF8DC")
# Input/prompt: leave unset by default so the typed text inherits
# the terminal's foreground color (readable in both light and dark
# color schemes). Skins can opt into a colored prompt by setting
# `prompt` explicitly in their YAML.
prompt = skin.get_color("prompt", "")
input_rule = skin.get_color("input_rule", "#CD7F32")
title = skin.get_color("banner_title", "#FFD700")
text = skin.get_color("banner_text", prompt)
text = skin.get_color("banner_text", "#FFF8DC")
dim = skin.get_color("banner_dim", "#555555")
label = skin.get_color("ui_label", title)
warn = skin.get_color("ui_warn", "#FF8C00")
@@ -851,7 +876,11 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg)
return {
"input-area": prompt,
# Typed input always uses terminal default fg/bg so it's
# readable in both light and dark Terminal.app modes. The
# skin's `prompt` color (if any) only styles the prompt symbol,
# NOT the user's typed text.
"input-area": "",
"placeholder": f"{dim} italic",
"prompt": prompt,
"prompt-working": f"{dim} italic",
-2
View File
@@ -141,8 +141,6 @@ def show_status(args):
"Browser Use": "BROWSER_USE_API_KEY", # Optional — local browser works without this
"Browserbase": "BROWSERBASE_API_KEY", # Optional — direct credentials only
"FAL": "FAL_KEY",
"Tinker": "TINKER_API_KEY",
"WandB": "WANDB_API_KEY",
"ElevenLabs": "ELEVENLABS_API_KEY",
"GitHub": "GITHUB_TOKEN",
}
+356 -111
View File
@@ -60,6 +60,7 @@ CONFIGURABLE_TOOLSETS = [
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
("image_gen", "🎨 Image Generation", "image_generate"),
("video_gen", "🎬 Video Generation", "video_generate (text-to-video + image-to-video)"),
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
("skills", "📚 Skills", "list, view, manage"),
@@ -70,7 +71,6 @@ CONFIGURABLE_TOOLSETS = [
("delegation", "👥 Task Delegation", "delegate_task"),
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("messaging", "📨 Cross-Platform Messaging", "send_message"),
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
("homeassistant", "🏠 Home Assistant", "smart home device control"),
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
@@ -82,7 +82,11 @@ CONFIGURABLE_TOOLSETS = [
# Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
# but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"}
#
# Video gen is off by default — it's a niche, paid, slow feature. Users
# who want it opt in via `hermes tools` → Video Generation, which walks
# them through provider + model selection.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen"}
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
@@ -190,11 +194,10 @@ TOOL_CATEGORIES = {
},
{
"name": "xAI TTS",
"tag": "Grok voices - requires xAI API key",
"env_vars": [
{"key": "XAI_API_KEY", "prompt": "xAI API key", "url": "https://console.x.ai/"},
],
"tag": "Grok voices — uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"tts_provider": "xai",
"post_setup": "xai_grok",
},
{
"name": "ElevenLabs",
@@ -240,6 +243,15 @@ TOOL_CATEGORIES = {
"setup_title": "Select Search Provider",
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
"icon": "🔍",
# Per-provider rows are injected at runtime from
# plugins.web.<vendor>.provider via _plugin_web_search_providers()
# in _visible_providers(). Only non-provider UX setup-flow rows
# for the firecrawl backend are listed here:
# - "Nous Subscription" — managed Firecrawl billed via Nous
# subscription (requires_nous_auth + override_env_vars).
# - "Firecrawl Self-Hosted" — points firecrawl at a private
# Docker instance via FIRECRAWL_API_URL only.
# See PR #25182 for the migration rationale.
"providers": [
{
"name": "Nous Subscription",
@@ -251,42 +263,6 @@ TOOL_CATEGORIES = {
"managed_nous_feature": "web",
"override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"],
},
{
"name": "Firecrawl Cloud",
"badge": "★ recommended",
"tag": "Full-featured search, extract, and crawl",
"web_backend": "firecrawl",
"env_vars": [
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
],
},
{
"name": "Exa",
"badge": "paid",
"tag": "Neural search with semantic understanding",
"web_backend": "exa",
"env_vars": [
{"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"},
],
},
{
"name": "Parallel",
"badge": "paid",
"tag": "AI-powered search and extract",
"web_backend": "parallel",
"env_vars": [
{"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"},
],
},
{
"name": "Tavily",
"badge": "free tier",
"tag": "Search, extract, and crawl — 1000 free searches/mo",
"web_backend": "tavily",
"env_vars": [
{"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
],
},
{
"name": "Firecrawl Self-Hosted",
"badge": "free · self-hosted",
@@ -296,32 +272,6 @@ TOOL_CATEGORIES = {
{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
],
},
{
"name": "SearXNG",
"badge": "free · self-hosted · search only",
"tag": "Privacy-respecting metasearch engine — search only (pair with any extract provider)",
"web_backend": "searxng",
"env_vars": [
{"key": "SEARXNG_URL", "prompt": "Your SearXNG instance URL (e.g., http://localhost:8080)", "url": "https://searxng.github.io/searxng/"},
],
},
{
"name": "Brave Search (Free Tier)",
"badge": "free tier · search only",
"tag": "2,000 queries/mo free — search only (pair with any extract provider)",
"web_backend": "brave-free",
"env_vars": [
{"key": "BRAVE_SEARCH_API_KEY", "prompt": "Brave Search subscription token", "url": "https://brave.com/search/api/"},
],
},
{
"name": "DuckDuckGo (ddgs)",
"badge": "free · no key · search only",
"tag": "Search via the ddgs Python package — no API key (pair with any extract provider)",
"web_backend": "ddgs",
"env_vars": [],
"post_setup": "ddgs",
},
],
},
"image_gen": {
@@ -349,6 +299,15 @@ TOOL_CATEGORIES = {
},
],
},
"video_gen": {
"name": "Video Generation",
"icon": "🎬",
# Providers list is intentionally empty — every video gen backend
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
# injected by ``_visible_providers``. Mirrors the design we'll
# converge image_gen toward.
"providers": [],
},
"browser": {
"name": "Browser Automation",
"icon": "🌐",
@@ -463,22 +422,6 @@ TOOL_CATEGORIES = {
},
],
},
"rl": {
"name": "RL Training",
"icon": "🧪",
"requires_python": (3, 11),
"providers": [
{
"name": "Tinker / Atropos",
"tag": "RL training platform",
"env_vars": [
{"key": "TINKER_API_KEY", "prompt": "Tinker API key", "url": "https://tinker-console.thinkingmachines.ai/keys"},
{"key": "WANDB_API_KEY", "prompt": "WandB API key", "url": "https://wandb.ai/authorize"},
],
"post_setup": "rl_training",
},
],
},
"langfuse": {
"name": "Langfuse Observability",
"icon": "📊",
@@ -951,24 +894,6 @@ def _run_post_setup(post_setup_key: str):
_print_warning(f" Spotify login failed: {exc}")
_print_info(" Run manually: hermes auth spotify")
elif post_setup_key == "rl_training":
try:
__import__("tinker_atropos")
except ImportError:
tinker_dir = PROJECT_ROOT / "tinker-atropos"
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
_print_info(" Installing tinker-atropos submodule...")
result = _pip_install(["-e", str(tinker_dir)])
if result.returncode == 0:
_print_success(" tinker-atropos installed")
else:
_print_warning(" tinker-atropos install failed - run manually:")
_print_info(' uv pip install -e "./tinker-atropos"')
else:
_print_warning(" tinker-atropos submodule not found - run:")
_print_info(" git submodule update --init --recursive")
_print_info(' uv pip install -e "./tinker-atropos"')
elif post_setup_key == "langfuse":
# Install the langfuse SDK.
try:
@@ -999,6 +924,73 @@ def _run_post_setup(post_setup_key: str):
_print_info(" Restart Hermes for tracing to take effect.")
_print_info(" Verify: hermes plugins list")
elif post_setup_key == "xai_grok":
# Shared credential bootstrap for any picker entry that talks to xAI
# (TTS, Video Gen, future Image Gen, etc.). Accepts either a
# SuperGrok-tier OAuth bearer token (preferred — billed against the
# user's existing subscription) or a raw XAI_API_KEY from
# console.x.ai. The picker entries declare empty env_vars so we
# drive the full auth UX here.
try:
from hermes_cli.auth import get_xai_oauth_auth_status
oauth_logged_in = bool(get_xai_oauth_auth_status().get("logged_in"))
except Exception:
oauth_logged_in = False
existing_api_key = get_env_value("XAI_API_KEY")
if oauth_logged_in:
_print_success(
" xAI will use your xAI Grok OAuth (SuperGrok Subscription) credentials"
)
return
if existing_api_key:
_print_success(" xAI will use your existing XAI_API_KEY")
return
_print_info(" xAI needs credentials. Choose one:")
try:
from hermes_cli.setup import (
_run_xai_oauth_login_from_setup,
prompt_choice,
prompt as _setup_prompt,
)
from hermes_cli.config import save_env_value
except Exception as exc:
_print_warning(f" Could not load setup helpers: {exc}")
_print_info(" Run later: hermes auth add xai-oauth (or set XAI_API_KEY)")
return
idx = prompt_choice(
" How do you want xAI to authenticate?",
choices=[
"Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login",
"Paste an xAI API key (console.x.ai)",
"Skip — configure later via `hermes auth add xai-oauth`",
],
default=0,
)
if idx == 0:
if _run_xai_oauth_login_from_setup():
_print_success(
" Logged in — xAI will use these OAuth credentials"
)
else:
_print_warning(
" xAI Grok OAuth login did not complete. "
"Run later: hermes auth add xai-oauth"
)
elif idx == 1:
api_key = _setup_prompt(" xAI API key", password=True)
if api_key:
save_env_value("XAI_API_KEY", api_key)
_print_success(" XAI_API_KEY saved")
else:
_print_warning(
" No API key provided. Run later: hermes auth add xai-oauth"
)
else:
_print_info(" xAI will remain inactive until credentials are configured.")
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
@@ -1513,15 +1505,112 @@ def _plugin_image_gen_providers() -> list[dict]:
continue
if not isinstance(schema, dict):
continue
rows.append(
{
"name": schema.get("name", provider.display_name),
"badge": schema.get("badge", ""),
"tag": schema.get("tag", ""),
"env_vars": schema.get("env_vars", []),
"image_gen_plugin_name": provider.name,
}
)
row = {
"name": schema.get("name", provider.display_name),
"badge": schema.get("badge", ""),
"tag": schema.get("tag", ""),
"env_vars": schema.get("env_vars", []),
"image_gen_plugin_name": provider.name,
}
if schema.get("post_setup"):
row["post_setup"] = schema["post_setup"]
rows.append(row)
return rows
def _plugin_video_gen_providers() -> list[dict]:
"""Build picker-row dicts from plugin-registered video gen providers.
Mirrors ``_plugin_image_gen_providers`` exactly every video backend
is a plugin, so this function is the *only* source of provider rows
for the Video Generation category. The hardcoded ``TOOL_CATEGORIES``
entry for ``video_gen`` keeps an empty providers list.
"""
try:
from agent.video_gen_registry import list_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
providers = list_providers()
except Exception:
return []
rows: list[dict] = []
for provider in providers:
try:
schema = provider.get_setup_schema()
except Exception:
continue
if not isinstance(schema, dict):
continue
row = {
"name": schema.get("name", provider.display_name),
"badge": schema.get("badge", ""),
"tag": schema.get("tag", ""),
"env_vars": schema.get("env_vars", []),
"video_gen_plugin_name": provider.name,
}
if schema.get("post_setup"):
row["post_setup"] = schema["post_setup"]
rows.append(row)
return rows
# Mirror of _plugin_image_gen_providers for web search backends. Surfaces
# every plugin-registered web provider so it appears in the
# "Web Search & Extract" picker. All seven providers (brave-free, ddgs,
# searxng, exa, parallel, tavily, firecrawl) live as plugins after
# PR #25182 — this helper is the sole source of truth for the category's
# provider rows. The hardcoded entries that used to drive the category
# were deleted in the same PR; only the two non-provider UX rows
# ("Nous Subscription" managed-gateway entry, "Firecrawl Self-Hosted")
# remain in TOOL_CATEGORIES because they describe alternative *setup
# flows* for the firecrawl backend rather than distinct providers.
def _plugin_web_search_providers() -> list[dict]:
"""Build picker-row dicts from plugin-registered web search providers.
Each returned dict is a regular ``TOOL_CATEGORIES`` provider row. It
populates both ``web_backend`` (legacy field consumed by setup +
selection helpers) and ``web_search_plugin_name`` (informational
marker) so the picker behaves identically whether a provider is
hardcoded or plugin-registered.
After PR #25182, all seven web providers (brave-free, ddgs, searxng,
exa, parallel, tavily, firecrawl) are plugins; this helper is the sole
source of provider rows for the Web Search & Extract category.
"""
try:
from agent.web_search_registry import list_providers as _list_web_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
providers = _list_web_providers()
except Exception:
return []
rows: list[dict] = []
for provider in providers:
name = getattr(provider, "name", None)
if not name:
continue
try:
schema = provider.get_setup_schema()
except Exception:
continue
if not isinstance(schema, dict):
continue
row = {
"name": schema.get("name", provider.display_name),
"badge": schema.get("badge", ""),
"tag": schema.get("tag", ""),
"env_vars": schema.get("env_vars", []),
"web_backend": name,
"web_search_plugin_name": name,
}
# Optional pass-through fields the schema can opt into.
if schema.get("post_setup"):
row["post_setup"] = schema["post_setup"]
rows.append(row)
return rows
@@ -1541,6 +1630,19 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
if cat.get("name") == "Image Generation":
visible.extend(_plugin_image_gen_providers())
# Inject plugin-registered video_gen backends. Unlike image_gen,
# video_gen has NO hardcoded providers — every backend is a plugin.
if cat.get("name") == "Video Generation":
visible.extend(_plugin_video_gen_providers())
# Inject plugin-registered web search backends. After PR #25182, this
# is the SOLE source of provider rows for the Web Search & Extract
# category — the per-provider hardcoded entries were deleted. The two
# remaining hardcoded rows ("Nous Subscription", "Firecrawl
# Self-Hosted") are non-provider UX setup-flow rows for firecrawl.
if cat.get("name") == "Web Search & Extract":
visible.extend(_plugin_web_search_providers())
return visible
@@ -1608,6 +1710,23 @@ def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
from agent.image_gen_registry import list_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
for provider in list_providers():
try:
if provider.is_available():
return False
except Exception:
continue
except Exception:
pass
return True
if ts_key == "video_gen":
# Satisfied when any plugin-registered video gen provider reports
# available — no in-tree fallback (every backend is a plugin).
try:
from agent.video_gen_registry import list_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
for provider in list_providers():
try:
@@ -1697,6 +1816,11 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
image_cfg = config.get("image_gen", {})
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
video_plugin_name = provider.get("video_gen_plugin_name")
if video_plugin_name:
video_cfg = config.get("video_gen", {})
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
managed_feature = provider.get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config)
@@ -1952,6 +2076,106 @@ def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
_configure_imagegen_model_for_plugin(plugin_name, config)
# ─── Video Generation Model Pickers ───────────────────────────────────────────
def _plugin_video_gen_catalog(plugin_name: str):
"""Return ``(catalog_dict, default_model_id)`` for a video gen plugin.
Mirrors :func:`_plugin_image_gen_catalog`. Returns ``({}, None)`` when
the plugin isn't registered or has no models.
"""
try:
from agent.video_gen_registry import get_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
provider = get_provider(plugin_name)
except Exception:
return {}, None
if provider is None:
return {}, None
try:
models = provider.list_models() or []
default = provider.default_model()
except Exception:
return {}, None
catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m}
return catalog, default
def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None:
"""Prompt for a video gen model from a plugin's catalog.
Mirrors :func:`_configure_imagegen_model_for_plugin`. Writes the
selection to ``video_gen.model``.
"""
catalog, default_model = _plugin_video_gen_catalog(plugin_name)
if not catalog:
return
cur_cfg = config.setdefault("video_gen", {})
if not isinstance(cur_cfg, dict):
cur_cfg = {}
config["video_gen"] = cur_cfg
current_model = cur_cfg.get("model") or default_model
if current_model not in catalog:
current_model = default_model
model_ids = list(catalog.keys())
ordered = [current_model] + [m for m in model_ids if m != current_model]
widths = {
"model": max(len(m) for m in model_ids),
"speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6),
"strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0),
}
print()
header = (
f" {'Model':<{widths['model']}} "
f"{'Speed':<{widths['speed']}} "
f"{'Strengths':<{widths['strengths']}} "
f"Price"
)
print(color(header, Colors.CYAN))
rows = []
for mid in ordered:
meta = catalog[mid]
row = (
f" {mid:<{widths['model']}} "
f"{meta.get('speed', ''):<{widths['speed']}} "
f"{meta.get('strengths', ''):<{widths['strengths']}} "
f"{meta.get('price', '')}"
)
if mid == current_model:
row += " ← currently in use"
rows.append(row)
idx = _prompt_choice(
f" Choose {plugin_name} model:",
rows,
default=0,
)
chosen = ordered[idx]
cur_cfg["model"] = chosen
_print_success(f" Model set to: {chosen}")
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
"""Persist a plugin-backed video generation provider selection."""
vid_cfg = config.setdefault("video_gen", {})
if not isinstance(vid_cfg, dict):
vid_cfg = {}
config["video_gen"] = vid_cfg
vid_cfg["provider"] = plugin_name
vid_cfg["use_gateway"] = False
_print_success(f" video_gen.provider set to: {plugin_name}")
_configure_videogen_model_for_plugin(plugin_name, config)
def _configure_provider(provider: dict, config: dict):
"""Configure a single provider - prompt for API keys and set config."""
env_vars = provider.get("env_vars", [])
@@ -2014,6 +2238,12 @@ def _configure_provider(provider: dict, config: dict):
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
return
# Plugin-registered video_gen provider — same flow, different
# registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
return
# Imagegen backends prompt for model selection after backend pick.
backend = provider.get("imagegen_backend")
if backend:
@@ -2062,6 +2292,10 @@ def _configure_provider(provider: dict, config: dict):
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
return
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
return
# Imagegen backends prompt for model selection after env vars are in.
backend = provider.get("imagegen_backend")
if backend:
@@ -2286,6 +2520,11 @@ def _reconfigure_provider(provider: dict, config: dict):
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
return
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
return
# Imagegen backends prompt for model selection on reconfig too.
backend = provider.get("imagegen_backend")
if backend:
@@ -2318,6 +2557,12 @@ def _reconfigure_provider(provider: dict, config: dict):
_select_plugin_image_gen_provider(plugin_name, config)
return
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
_select_plugin_video_gen_provider(video_plugin, config)
return
backend = provider.get("imagegen_backend")
if backend:
_configure_imagegen_model(backend, config)
+2 -32
View File
@@ -1232,39 +1232,9 @@ def get_model_options():
can share the same types.
"""
try:
from hermes_cli.model_switch import list_authenticated_providers
from hermes_cli.inventory import build_models_payload, load_picker_context
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
current_provider = model_cfg.get("provider", "") or ""
current_base_url = model_cfg.get("base_url", "") or ""
else:
current_model = str(model_cfg) if model_cfg else ""
current_provider = ""
current_base_url = ""
user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
custom_providers = (
cfg.get("custom_providers")
if isinstance(cfg.get("custom_providers"), list)
else []
)
providers = list_authenticated_providers(
current_provider=current_provider,
current_base_url=current_base_url,
current_model=current_model,
user_providers=user_providers,
custom_providers=custom_providers,
max_models=50,
)
return {
"providers": providers,
"model": current_model,
"provider": current_provider,
}
return build_models_payload(load_picker_context(), max_models=50)
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")