- new hermes_cli/status_auth.py: _render_api_keys / _render_auth_providers / _render_nous_gateway / _render_apikey_providers + their data tables and the OAuth/timestamp helpers; origin helpers imported lazily so status monkeypatches hold - status.redact_key was a verbatim copy of hermes_cli.config.redact_key -> imported - sessions/cron/deep/footer renderers compacted; 624 -> 372 lines (base 639) Parity: r2m_status_golden.py 33 fixtures byte-identical vs base; real `hermes status`, `status --deep`, `status --help` byte-identical after normalizing the per-run temp HERMES_HOME path; test_status*/test_estop/test_jobs_json_utf8_bom green.
229 lines
10 KiB
Python
229 lines
10 KiB
Python
"""Credential sections of `hermes status`: API keys, OAuth providers, Nous Tool Gateway,
|
|
API-key providers. Split out of ``hermes_cli/status.py``; the renderers are re-imported there
|
|
and run through ``status._SECTIONS`` with the shared ``_StatusContext``.
|
|
|
|
Origin helpers (``_row``, ``_first_env_value``, ...) are imported lazily from
|
|
``hermes_cli.status`` so tests that monkeypatch that module keep working.
|
|
"""
|
|
|
|
from hermes_cli.auth import AuthError
|
|
from hermes_cli.nous_account import (
|
|
format_nous_portal_entitlement_message,
|
|
get_nous_portal_account_info,
|
|
)
|
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
|
|
|
|
|
def _format_iso_timestamp(value) -> str:
|
|
"""Format ISO timestamps for status output, converting to local timezone."""
|
|
text = value.strip() if isinstance(value, str) else ""
|
|
if not text:
|
|
return "(unknown)"
|
|
from datetime import datetime, timezone
|
|
try:
|
|
parsed = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
|
|
except Exception:
|
|
return value
|
|
return (parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
|
|
|
|
def _qwen_expiry(expires_at_ms) -> str:
|
|
from datetime import datetime, timezone
|
|
return datetime.fromtimestamp(int(expires_at_ms) / 1000, tz=timezone.utc).isoformat()
|
|
|
|
|
|
def _oauth_block(name: str, status: dict, hint: str, rows) -> None:
|
|
"""Print an OAuth provider row plus its conditional detail lines.
|
|
|
|
``rows`` are ``(label, status_key, formatter, gate)``: a detail prints when the raw value is
|
|
truthy and ``gate`` is None or equals the logged-in state (False = only while logged out).
|
|
"""
|
|
from hermes_cli.status import _detail, _row
|
|
logged_in = bool(status.get("logged_in"))
|
|
_row(name, logged_in, "logged in" if logged_in else f"not logged in (run: {hint})")
|
|
for label, key, fmt, gate in rows:
|
|
raw = status.get(key)
|
|
if raw and (gate is None or gate == logged_in):
|
|
_detail(label, fmt(raw) if fmt else raw)
|
|
|
|
|
|
# Values may be a single env var name (str) or a tuple of alternates (first found wins).
|
|
_API_KEYS: dict[str, str | tuple[str, ...]] = {
|
|
"OpenRouter": "OPENROUTER_API_KEY",
|
|
"OpenAI": "OPENAI_API_KEY",
|
|
"Google / Gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
"DeepSeek": "DEEPSEEK_API_KEY",
|
|
"xAI / Grok": "XAI_API_KEY",
|
|
"NVIDIA NIM": "NVIDIA_API_KEY",
|
|
"Z.AI / GLM": "GLM_API_KEY",
|
|
"Kimi": "KIMI_API_KEY",
|
|
"StepFun Step Plan": "STEPFUN_API_KEY",
|
|
"MiniMax": "MINIMAX_API_KEY",
|
|
"MiniMax-CN": "MINIMAX_CN_API_KEY",
|
|
"DeepInfra": "DEEPINFRA_API_KEY",
|
|
"Firecrawl": "FIRECRAWL_API_KEY",
|
|
"Tavily": "TAVILY_API_KEY",
|
|
"Keenable": "KEENABLE_API_KEY",
|
|
"Browser Use": "BROWSER_USE_API_KEY", # Optional — local browser works without this
|
|
"Browserbase": "BROWSERBASE_API_KEY", # Optional — direct credentials only
|
|
"FAL": "FAL_KEY",
|
|
"ElevenLabs": "ELEVENLABS_API_KEY",
|
|
"GitHub": "GITHUB_TOKEN",
|
|
}
|
|
|
|
|
|
# OAuth detail rows: (label, status key, formatter, gate) — see _oauth_block.
|
|
_FILE_REFRESH_ROWS = (
|
|
("Auth file:", "auth_store", None, None),
|
|
("Refreshed:", "last_refresh", _format_iso_timestamp, None),
|
|
("Error:", "error", None, False),
|
|
)
|
|
|
|
|
|
_OAUTH_BLOCKS = (
|
|
# (row name, auth getter, login hint, detail rows)
|
|
("OpenAI Codex", "get_codex_auth_status", "hermes model", _FILE_REFRESH_ROWS),
|
|
("Qwen OAuth", "get_qwen_auth_status", "qwen auth qwen-oauth", (
|
|
("Auth file:", "auth_file", None, None),
|
|
("Access exp:", "expires_at_ms", _qwen_expiry, None),
|
|
("Error:", "error", None, False),
|
|
)),
|
|
("MiniMax OAuth", "get_minimax_oauth_auth_status", "hermes auth add minimax-oauth", (
|
|
("Region:", "region", None, True),
|
|
("Access exp:", "expires_at", None, None),
|
|
("Error:", "error", None, False),
|
|
)),
|
|
("xAI OAuth", "get_xai_oauth_auth_status", "hermes auth add xai-oauth", _FILE_REFRESH_ROWS),
|
|
)
|
|
|
|
|
|
_APIKEY_PROVIDERS = {
|
|
"Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
|
"Kimi / Moonshot": ("KIMI_API_KEY",),
|
|
"StepFun Step Plan": ("STEPFUN_API_KEY",),
|
|
"MiniMax": ("MINIMAX_API_KEY",),
|
|
"MiniMax (China)": ("MINIMAX_CN_API_KEY",),
|
|
"DeepInfra": ("DEEPINFRA_API_KEY",),
|
|
}
|
|
|
|
|
|
def _render_api_keys(ctx):
|
|
from hermes_cli.status import _first_env_value, _row, _section, redact_key
|
|
_section("API Keys")
|
|
for name, env_ref in _API_KEYS.items():
|
|
value = _first_env_value(env_ref)
|
|
_row(name, bool(value), redact_key(value))
|
|
# Anthropic uses the dedicated lookup (it also resolves OAuth tokens).
|
|
from hermes_cli.auth import get_anthropic_key
|
|
anthropic_value = get_anthropic_key()
|
|
_row("Anthropic", bool(anthropic_value), redact_key(anthropic_value))
|
|
|
|
|
|
def _render_auth_providers(ctx):
|
|
from hermes_cli.status import _detail, _row, _section
|
|
_section("Auth Providers")
|
|
import hermes_cli.auth as auth
|
|
try:
|
|
# Read-only display: use the refresh-free snapshot so `hermes status`
|
|
# never performs an OAuth refresh or burns a single-use refresh token.
|
|
nous_status = auth.get_nous_auth_status_local()
|
|
statuses = {getter: getattr(auth, getter)() for _, getter, _, _ in _OAUTH_BLOCKS[:3]}
|
|
except Exception:
|
|
nous_status, statuses = {}, {}
|
|
# xAI OAuth is guarded separately so an import failure there cannot disrupt
|
|
# the Nous/Codex/Qwen/MiniMax rows.
|
|
try:
|
|
statuses["get_xai_oauth_auth_status"] = auth.get_xai_oauth_auth_status() or {}
|
|
except Exception:
|
|
statuses["get_xai_oauth_auth_status"] = {}
|
|
|
|
info = None
|
|
if any(nous_status.get(k) for k in ("logged_in", "access_token", "portal_base_url",
|
|
"inference_credential_present", "error_code")):
|
|
try:
|
|
info = get_nous_portal_account_info()
|
|
except Exception:
|
|
info = None
|
|
ctx.nous_account_info = info
|
|
ctx.nous_logged_in = logged_in = bool(nous_status.get("logged_in") or (info and info.logged_in))
|
|
ctx.nous_inference_present = inference = bool(
|
|
nous_status.get("inference_credential_present") or (info and info.inference_credential_present)
|
|
)
|
|
nous_error = nous_status.get("error")
|
|
_row(
|
|
"Nous Portal", logged_in,
|
|
"logged in" if logged_in
|
|
else "not logged in (Nous inference key configured)" if inference
|
|
else "not logged in (run: hermes portal)",
|
|
)
|
|
portal_url = nous_status.get("portal_base_url") or "(unknown)"
|
|
inference_url = nous_status.get("inference_base_url") or (info.inference_base_url if info else None)
|
|
for label, value, show in (
|
|
("Portal URL:", portal_url, logged_in or portal_url != "(unknown)" or nous_error),
|
|
("Inference:", inference_url, inference and inference_url),
|
|
("Access exp:", _format_iso_timestamp(nous_status.get("access_expires_at")),
|
|
logged_in or nous_status.get("access_expires_at")),
|
|
("Key exp:", _format_iso_timestamp(nous_status.get("agent_key_expires_at")),
|
|
logged_in or inference or nous_status.get("agent_key_expires_at")),
|
|
("Refresh:", "yes" if nous_status.get("has_refresh_token") else "no",
|
|
logged_in or nous_status.get("has_refresh_token")),
|
|
("Error:", nous_error, nous_error),
|
|
):
|
|
if show:
|
|
_detail(label, value)
|
|
for name, getter, hint, rows in _OAUTH_BLOCKS:
|
|
_oauth_block(name, statuses.get(getter, {}), hint, rows)
|
|
|
|
|
|
def _render_nous_gateway(ctx):
|
|
from hermes_cli.status import _section, check_mark
|
|
if managed_nous_tools_enabled():
|
|
features = get_nous_subscription_features(ctx.config)
|
|
_section("Nous Tool Gateway")
|
|
print(" Nous Portal ✓ managed tools available" if features.nous_auth_present else " Nous Portal ✗ not logged in")
|
|
for f in features.items():
|
|
if f.managed_by_nous:
|
|
state = "active via Nous subscription"
|
|
elif f.active:
|
|
state = f"active via {f.current_provider or 'configured provider'}"
|
|
elif f.included_by_default and features.nous_auth_present:
|
|
state = "included by subscription, not currently selected"
|
|
elif f.key == "modal" and features.nous_auth_present:
|
|
state = "available via subscription (optional)"
|
|
else:
|
|
state = "not configured"
|
|
print(f" {f.label:<15} {check_mark(f.available or f.active or f.managed_by_nous)} {state}")
|
|
elif ctx.nous_logged_in or ctx.nous_inference_present:
|
|
# Nous OAuth without entitlement, or an opaque inference key without
|
|
# Portal account information, cannot enable the Tool Gateway.
|
|
_section("Nous Tool Gateway")
|
|
message = format_nous_portal_entitlement_message(
|
|
ctx.nous_account_info, capability="managed web, image, TTS, STT, browser, and Modal tools"
|
|
)
|
|
for line in (message or "").splitlines():
|
|
print(f" {line}")
|
|
|
|
|
|
def _render_apikey_providers(ctx):
|
|
from hermes_cli.status import _effective_provider_label, _first_env_value, _section, check_mark, get_env_value
|
|
_section("API-Key Providers")
|
|
for pname, env_vars in _APIKEY_PROVIDERS.items():
|
|
configured = bool(_first_env_value(env_vars))
|
|
print(f" {pname:<16} {check_mark(configured)} {'configured' if configured else 'not configured (run: hermes model)'}")
|
|
|
|
# LM Studio reachability: probe only when it is the active provider so users
|
|
# with foreign configs see no noise. Auth rejection vs. a silent empty list
|
|
# is the most common LM Studio support case.
|
|
if _effective_provider_label() == "LM Studio":
|
|
from hermes_cli.models import probe_lmstudio_models
|
|
model_cfg = ctx.config.get("model")
|
|
base = (model_cfg.get("base_url") if isinstance(model_cfg, dict) else None) or get_env_value("LM_BASE_URL") or "http://127.0.0.1:1234/v1"
|
|
try:
|
|
models = probe_lmstudio_models(api_key=get_env_value("LM_API_KEY") or "", base_url=base, timeout=1.5)
|
|
ok = models is not None
|
|
msg = f"reachable ({len(models)} model(s)) at {base}" if ok else f"unreachable at {base}"
|
|
except AuthError:
|
|
ok, msg = False, "auth rejected — set LM_API_KEY"
|
|
print(f" {'LM Studio':<16} {check_mark(ok)} {msg}")
|