Merge main into bb/gui.
Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
+864
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ _hermes() {{
|
||||
esac
|
||||
}}
|
||||
|
||||
_hermes "$@"
|
||||
compdef _hermes hermes
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+81
-23
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user