* feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous provider) instead of forcing the setup wizard. The identity is persisted through the same path a real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off. * test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin * docs(user-guide): free tier and signing in New page explaining what a fresh install gets before any key or sign-in (free inference on nous/welcome plus connectors), how the free tier coexists with a user's own API key, how to sign in with hermes auth upgrade and keep connectors, how to turn the free tier off with nous.guest, what hermes logout does in each state, a troubleshooting table, and a plain privacy note. Wired into the Using Hermes sidebar. * fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user is told they were never signed in. Logging out of a real Nous account now also clears the cross-profile store, so a profile logout is not silently re-adopted on the next boot. * fix(model): switching off the free tier points at signing in, never hops providers * Name the free tier in the gateway startup notice and tell explicit-provider installs about it once * Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token * fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free tier before declaring nothing configured. On a fresh install the first command lands in chat on nous/welcome; a failed setup still falls through to the existing guidance. * Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors The device-code flow runs as usual, with a promotion intent registered on the portal between the code request and the token poll so the account that approves the code inherits the free tier's connectors. The promotion status decides the outcome: only a completed one is followed by the token grant, which is persisted over the free-tier singleton and the shared store. Declined, superseded, retired and busy outcomes each print their own plain copy, and a retired identity is cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals. * Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off * fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the claim code, not the generic device page. A failed mint is attempted once per process so several bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check. * fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure A credential-pool entry can select a paid Nous key while the profile singleton is still the free tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and the pin in model normalization is removed since it had no route to look at. A failed background identity setup releases its latch so a later attempt in the same process can try again. * fix(auth): decide the Nous model together with the route on every credential-pool swap The credential pool can move a Nous agent between the welcome host and the portal host after init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model. * fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start * fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the shared account. Locks are taken in the documented order (profile, then shared). A minted credential is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and trigger a second mint. Retiring a dead credential removes only that credential from both stores. Guest exchange uses the resolver's canonical portal URL. * fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential The welcome host serves one model, so a rotation onto it is refused for any conversation on another model instead of silently switching that conversation to nous/welcome (the model pin applies only when a route is first chosen). The connector token path now treats the free tier as absent when nous.guest is false, including cached tokens, and shares the one dead-credential rule with inference: a retired identity is replaced once rather than returning its stale token. * fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only A free-tier identity in the shared store is not an OAuth credential to offer for import; a real sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted state (no token refresh at boot), so an expired free-tier token cannot stall the online message.
205 lines
11 KiB
Python
205 lines
11 KiB
Python
"""Credential sections of `hermes status`, run through ``status._SECTIONS`` with its shared context.
|
|
Origin helpers (``_row``, ``_first_env_value``, ...) are resolved through the ``hermes_cli.status``
|
|
module object so tests that monkeypatch that module keep working."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
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
|
|
from hermes_cli import config
|
|
|
|
|
|
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)"
|
|
try:
|
|
parsed = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
|
|
except Exception:
|
|
return value
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
|
|
|
|
def _qwen_expiry(expires_at_ms) -> str:
|
|
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).
|
|
"""
|
|
logged_in = bool(status.get("logged_in"))
|
|
_status._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):
|
|
_status._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", "Perplexity": "PERPLEXITY_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",)}
|
|
|
|
# Nous Tool Gateway per-feature state: first matching (predicate(feature, nous_auth), text(feature)).
|
|
_FEATURE_STATES = (
|
|
(lambda f, _: f.managed_by_nous, lambda f: "active via Nous subscription"),
|
|
(lambda f, _: f.active, lambda f: f"active via {f.current_provider or 'configured provider'}"),
|
|
(lambda f, auth: f.included_by_default and auth, lambda f: "included by subscription, not currently selected"),
|
|
(lambda f, auth: f.key == "modal" and auth, lambda f: "available via subscription (optional)"))
|
|
|
|
|
|
def _render_api_keys(ctx):
|
|
_status._section("API Keys")
|
|
from hermes_cli.auth import get_anthropic_key
|
|
# Anthropic uses the dedicated lookup (it also resolves OAuth tokens).
|
|
for name, env_ref in (*_API_KEYS.items(), ("Anthropic", get_anthropic_key)):
|
|
value = env_ref() if callable(env_ref) else _status._first_env_value(env_ref)
|
|
_status._row(name, bool(value), config.redact_key(value))
|
|
|
|
|
|
def _render_auth_providers(ctx):
|
|
_status._section("Auth Providers")
|
|
import hermes_cli.auth as auth
|
|
try:
|
|
# Read-only display: 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 other 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:
|
|
pass
|
|
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)
|
|
)
|
|
if nous_status.get("free_tier"):
|
|
# Free tier: never rendered as an account login (no account ids, no refresh row).
|
|
from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT
|
|
_status._row("Nous Portal", True, f"{FREE_TIER_LABEL} · {GUEST_MODEL}")
|
|
_status._detail("", UPGRADE_HINT)
|
|
inference_url = nous_status.get("inference_base_url")
|
|
if inference_url:
|
|
_status._detail("Inference:", inference_url)
|
|
for name, getter, hint, rows in _OAUTH_BLOCKS:
|
|
_oauth_block(name, statuses.get(getter, {}), hint, rows)
|
|
return
|
|
nous_error = nous_status.get("error")
|
|
_status._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:
|
|
_status._detail(label, value)
|
|
for name, getter, hint, rows in _OAUTH_BLOCKS:
|
|
_oauth_block(name, statuses.get(getter, {}), hint, rows)
|
|
|
|
|
|
def _render_nous_gateway(ctx):
|
|
if managed_nous_tools_enabled():
|
|
features = get_nous_subscription_features(ctx.config)
|
|
_status._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():
|
|
state = next((text(f) for match, text in _FEATURE_STATES if match(f, features.nous_auth_present)),
|
|
"not configured")
|
|
_status._row(f.label, f.available or f.active or f.managed_by_nous, state, 15, " ")
|
|
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.
|
|
_status._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):
|
|
_status._section("API-Key Providers")
|
|
for pname, env_vars in _APIKEY_PROVIDERS.items():
|
|
configured = bool(_status._first_env_value(env_vars))
|
|
_status._row(pname, configured, "configured" if configured else "not configured (run: hermes model)", 16, " ")
|
|
|
|
# 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 common support case.
|
|
if _status._effective_provider_label() == "LM Studio":
|
|
from hermes_cli.models_local import probe_lmstudio_models
|
|
model_cfg = ctx.config.get("model")
|
|
base = ((model_cfg.get("base_url") if isinstance(model_cfg, dict) else None)
|
|
or _status.get_env_value("LM_BASE_URL") or "http://127.0.0.1:1234/v1")
|
|
try:
|
|
models = probe_lmstudio_models(api_key=_status.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"
|
|
_status._row("LM Studio", ok, msg, 16, " ")
|
|
|
|
|
|
import hermes_cli.status as _status # noqa: E402 (bottom: hermes_cli.status imports this module)
|