diff --git a/agent/agent_init.py b/agent/agent_init.py index aa6574e654..e9a1fd4503 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -457,6 +457,11 @@ def _finalize_routing(agent, api_mode, credential_pool): if agent.provider not in _AGGREGATOR_PROVIDERS: agent.model = normalize_model_for_provider(agent.model, agent.provider) + # Nous model policy follows the ROUTE (the welcome host serves one model); a credential-pool + # swap can change the route later, so ``_swap_credential`` applies the same helper again. + from hermes_cli.anon_auth import pin_model_for_route + agent.model = pin_model_for_route(agent.provider, agent.base_url, agent.model) + # Auto-upgrade to Responses for GPT-5.x-style models and direct OpenAI URLs, unless # api_mode was explicit, the runtime is ACP (`acp://` clients route themselves, no # Responses surface) or Azure OpenAI (gpt-5.x on /chat/completions only). Provider diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3cb43761ba..df57cae665 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -749,7 +749,8 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co ) return False, has_retried_429 _ra().logger.info("Credential auth failure — refreshed pool entry %s", getattr(refreshed, 'id', '?')) - agent._swap_credential(refreshed) + if agent._swap_credential(refreshed) is False: + return False, has_retried_429 return True, has_retried_429 @@ -847,8 +848,7 @@ def recover_with_credential_pool( "Credential %s (%s) — rotated to pool entry %s", rotate_status, label, getattr(next_entry, "id", "?"), ) - agent._swap_credential(next_entry) - return True + return agent._swap_credential(next_entry) is not False if effective_reason == FailoverReason.upstream_rate_limit: # Upstream (e.g. DeepSeek behind OpenRouter) is throttling the aggregator; the credential is # healthy. Do not rotate/exhaust; let fallback switch models. diff --git a/agent/client_lifecycle.py b/agent/client_lifecycle.py index eb2dc19eb8..06e3f18163 100644 --- a/agent/client_lifecycle.py +++ b/agent/client_lifecycle.py @@ -917,20 +917,30 @@ class ClientLifecycleMixin: if merged: self._client_kwargs["default_headers"] = merged - def _swap_credential(self, entry) -> None: + def _swap_credential(self, entry) -> bool: + """Adopt *entry* as the live credential. Returns False, changing nothing, when the entry's + route cannot serve this conversation's model (a conversation's model is never rewritten by a + rotation; the caller treats a refused swap as "no entry").""" runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url from hermes_cli.providers import is_actual_route - if is_actual_route(getattr(self, "provider", ""), runtime_base): + actual_route = is_actual_route(getattr(self, "provider", ""), runtime_base) + if actual_route: from hermes_cli.auth import normalize_actual_base_url runtime_base = normalize_actual_base_url(runtime_base) + stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base + # Refuse BEFORE any state changes below: a refused swap must leave the agent exactly as it was. + from hermes_cli.anon_auth import route_can_serve_model + if not route_can_serve_model(getattr(self, "provider", None), stripped_base, getattr(self, "model", None)): + logger.info("Credential %s skipped: its route cannot serve model %s", getattr(entry, "id", "?"), self.model) + return False + if actual_route: self.api_mode = "chat_completions" if hasattr(self, "_transport_cache"): self._transport_cache.clear() self._credential_pool_entry_id = getattr(entry, "id", None) from hermes_cli.route_identity import normalize_route_base_url route_changed = normalize_route_base_url(self.base_url) != normalize_route_base_url(runtime_base) - stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base if self.api_mode == "anthropic_messages": with suppress(Exception): self._anthropic_client.close() @@ -938,13 +948,14 @@ class ClientLifecycleMixin: self._anthropic_client = self._build_direct_anthropic_client(runtime_key, self._anthropic_base_url) self._is_anthropic_oauth = self._anthropic_oauth_flag(runtime_key) self.api_key, self.base_url = runtime_key, stripped_base - return + return True self.api_key, self.base_url = runtime_key, stripped_base # Inlined (not _sync_client_kwargs_credentials): tests call this unbound on a SimpleNamespace agent. self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url self._reapply_route_client_config(route_changed=route_changed) self._replace_primary_openai_client(reason="credential_rotation") + return True def _reapply_route_client_config(self, *, route_changed: bool) -> None: """Recompute route-derived client kwargs (TLS material, default headers) for ``self.base_url``. diff --git a/agent/credential_pool.py b/agent/credential_pool.py index a311cae1e3..b76e53d866 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -167,6 +167,8 @@ _EXTRA_KEYS = frozenset({ "token_type", "scope", "client_id", "portal_base_url", "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint", + # Nous guest identity (``auth_method: anonymous``): the anon_ credential is the refresh material. + "auth_method", "account_tier", "anon_token", "user_id", "org_id", # Classified failure semantics for the last exhaustion (agent/error_classifier.py). # Providers return 403 for both an edge throttle and a spending limit, so the # raw status cannot size a cooldown; persisted so a restart doesn't downgrade @@ -178,6 +180,7 @@ _EXTRA_KEYS = frozenset({ _NOUS_EXTRA_STATE_KEYS = ( "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", + "auth_method", "account_tier", "anon_token", "user_id", "org_id", ) # ``replace(entry, **_CLEAR_STATUS)`` returns an entry with no error state. diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 205e0166aa..1d9f821c4f 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -742,6 +742,24 @@ class GatewayNotificationsMixin: logger.warning(failure_fmt, platform.value, home.chat_id, exc) return False + def _free_tier_startup_line(self) -> Optional[str]: + """Extra startup line when the gateway's inference is carried by the Nous free tier; None otherwise. + + Best-effort: a resolution failure (no provider, auth error) must not block the online notice.""" + try: + # Persisted state only: provider precedence is answered by the resolver WITHOUT touching + # the network (no token refresh at boot), and the free-tier check reads auth.json. + from hermes_cli.auth import resolve_provider + from hermes_cli.anon_auth import guest_carries_inference + if resolve_provider("auto") != "nous": + return None + if not guest_carries_inference(): + return None + except Exception as exc: + logger.debug("Free tier startup line skipped: %s", exc) + return None + return "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade" + async def _send_home_channel_startup_notifications( self, *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None ) -> set[tuple[str, str, Optional[str]]]: @@ -753,6 +771,9 @@ class GatewayNotificationsMixin: delivered: set[tuple[str, str, Optional[str]]] = set() skipped = skip_targets or set() message = "♻️ Gateway online — Hermes is back and ready." + free_tier_line = self._free_tier_startup_line() + if free_tier_line: + message = f"{message}\n{free_tier_line}" for platform, platform_cfg, home, transport in self._home_channel_transports(): if not platform_cfg.gateway_restart_notification: logger.info( diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py new file mode 100644 index 0000000000..cb42c7cd33 --- /dev/null +++ b/hermes_cli/anon_auth.py @@ -0,0 +1,650 @@ +"""Nous guest identity: the ``anonymous`` auth method of the ``nous`` provider. + +A fresh install mints an anonymous Nous account (``POST /api/anonymous/create``) and exchanges its +``anon_`` credential for short-lived JWTs (``POST /api/anonymous/token``). The result is persisted +through the same ``persist_nous_credentials`` a real login uses, so it is the singleton +``providers.nous`` *and* ``active_provider`` -- the resolver ladder (``resolve_provider``) is +untouched; ``active_provider`` is already its last-resort rung, so any explicit provider (env key, +``model.provider``, OpenRouter pool) beats the guest for inference while the guest keeps carrying the +tool-gateway JWT for connectors. + +Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``: +token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing +(the welcome inference host, single model ``nous/welcome``). + +Users are never shown the words guest / anonymous / account for this state: surfaces say +"Nous · free tier". The one user-facing verb is ``hermes auth upgrade`` (sign in, keeping the +identity's connectors). + +Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already +holds, else mint under the shared-store lock. It is the only minter; nothing else calls +:func:`mint_guest`. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from hermes_cli.auth_constants import ( + AuthError, DEFAULT_NOUS_PORTAL_URL, _decode_jwt_claims, httpx) + +logger = logging.getLogger("hermes_cli.auth") + +ANON_AUTH_METHOD = "anonymous" +ANON_CLIENT_ID = "nas-anonymous" +ANON_ACCOUNT_TIER = "anonymous" +GUEST_MODEL = "nous/welcome" +ANON_SECRET_HEADER = "x-anonymous-api-secret" +# The shared secret gates the anonymous surface during its integration phase. It is a deployment +# secret (Sid's), read from the environment only. +ANON_SECRET_ENV = "HERMES_ANON_API_SECRET" +# Dev lever: "1" makes the guest carry inference even when explicit providers exist; "new" also +# bypasses the shared store and mints a fresh guest for this process. Overrides ``nous.guest: false``. +FORCE_GUEST_ENV = "HERMES_FORCE_GUEST" +GUEST_MINT_TIMEOUT_SECONDS = 5.0 +# Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account. +FREE_TIER_LABEL = "Nous · free tier" +UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account." +FREE_TIER_NOT_SIGNED_IN = ( + "You're not signed in. Free inference and connectors are always on. " + "Run `hermes auth` to sign in with a Nous account.") + + +class AnonCredentialDead(AuthError): + """NAS no longer knows this ``anon_`` credential (reaped, or claimed into a real account). + + The one client rule for reap AND claim: mark dead, re-mint on the next need. + """ + + +def _anon_err(message: str, code: str) -> AuthError: + return AuthError(message, code=code) + + +def force_guest_mode() -> str: + """``""`` (off), ``"1"`` or ``"new"``; anything else truthy counts as ``"1"``.""" + raw = (os.environ.get(FORCE_GUEST_ENV) or "").strip().lower() + if not raw or raw in {"0", "false", "no", "off"}: + return "" + return "new" if raw == "new" else "1" + + +def guest_enabled() -> bool: + """``nous.guest`` (default True), overridden by the dev lever.""" + if force_guest_mode(): + return True + try: + from hermes_cli.config import load_config_readonly + nous_cfg = load_config_readonly().get("nous") + except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint + logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc) + return False + if not isinstance(nous_cfg, dict): + return True + return bool(nous_cfg.get("guest", True)) + + +def is_guest_state(state: Any) -> bool: + return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD + + +def current_nous_state() -> Optional[Dict[str, Any]]: + """The profile's ``providers.nous`` state without locking or network (status/picker reads).""" + from hermes_cli.auth import _load_auth_store, _load_provider_state + try: + return _load_provider_state(_load_auth_store(), "nous") + except Exception as exc: + logger.debug("guest: auth store unreadable: %s", exc) + return None + + +def has_guest() -> bool: + return is_guest_state(current_nous_state()) + + +def guest_carries_inference() -> bool: + """True when the profile's Nous identity is the free tier and the free tier is on. + + Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a + request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a + credential-pool entry can pick a paid Nous key while the profile singleton is still a guest. + """ + return guest_enabled() and has_guest() + + +WELCOME_HOSTS = frozenset({"welcome-api.nousresearch.com"}) + + +def pin_model_for_route(provider: Any, base_url: Any, model: Any) -> Any: + """Model policy at agent START: on the Nous welcome host the model is ``nous/welcome``; anywhere + else the caller's model stands. Used once, when the route is first finalized. Mid-conversation + route changes go through :func:`route_can_serve_model` instead: a conversation's model is never + silently rewritten by a credential rotation. + """ + if provider == "nous" and route_is_welcome_host(base_url): + if model and model != GUEST_MODEL: + logger.info("Nous free tier: using %s instead of configured model %s", GUEST_MODEL, model) + return GUEST_MODEL + return model + + +def route_can_serve_model(provider: Any, base_url: Any, model: Any) -> bool: + """Eligibility for a credential ROTATION: the welcome host serves only ``nous/welcome``, so a + conversation on any other model must not be rotated onto it (and a ``nous/welcome`` conversation + may move to the portal host, which serves it too). Non-Nous routes are always eligible.""" + if provider != "nous" or not route_is_welcome_host(base_url): + return True + return not model or model == GUEST_MODEL + + +def route_is_welcome_host(base_url: Any) -> bool: + """The routing predicate for the free tier: the welcome host serves exactly ``nous/welcome``. + + Keyed on the resolved endpoint, never on profile state, so a paid pool credential routed to the + portal host keeps its model even when a guest singleton exists beside it. + """ + from urllib.parse import urlparse + try: + host = (urlparse(str(base_url or "")).hostname or "").lower() + except ValueError: + return False + return host in WELCOME_HOSTS + + +def anon_secret() -> str: + return (os.environ.get(ANON_SECRET_ENV) or "").strip() + + +def _anon_headers() -> Dict[str, str]: + headers = {"content-type": "application/json"} + if secret := anon_secret(): + headers[ANON_SECRET_HEADER] = secret + return headers + + +def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]: + try: + payload = response.json() + except ValueError: + payload = {} + if not isinstance(payload, dict): + payload = {} + error = str(payload.get("error") or "") + if response.status_code in (200, 201): + return payload + if response.status_code == 404 and error == "unknown_token": + raise AnonCredentialDead("Nous free-tier credential is no longer valid.", code="anon_credential_dead") + if response.status_code == 401 and error == "invalid_shared_secret": + raise _anon_err("Nous free tier is not open on this portal.", "anon_gate_closed") + if response.status_code == 401: + raise AnonCredentialDead("Nous free-tier credential was revoked.", code="anon_credential_dead") + if response.status_code == 429: + raise _anon_err("Nous free tier is rate limited; try again shortly.", "anon_rate_limited") + if response.status_code == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}: + raise _anon_err("Nous free tier is currently disabled.", "anon_gate_closed") + raise _anon_err( + f"Nous free tier {action} failed ({response.status_code}{': ' + error if error else ''}).", + "anon_server_error") + + +def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]: + """``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once.""" + response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={}) + payload = _raise_for_anon_status(response, action="sign-up") + token = payload.get("token") + if not isinstance(token, str) or not token.startswith("anon_"): + raise _anon_err("Nous free tier sign-up returned no credential.", "anon_server_error") + return payload + + +def exchange_anon_jwt(client: httpx.Client, portal_base_url: str, anon_token: str) -> Dict[str, Any]: + """``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``. + + Raises :class:`AnonCredentialDead` on 404 ``unknown_token`` / 401 (reaped or claimed). + """ + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/token", headers=_anon_headers(), json={"token": anon_token}) + payload = _raise_for_anon_status(response, action="token exchange") + if not isinstance(payload.get("access_token"), str) or not payload["access_token"]: + raise _anon_err("Nous free tier token exchange returned no token.", "anon_server_error") + return payload + + +def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> None: + """Write a fresh exchange result into a guest state in place (token, expiry, routing).""" + from hermes_cli.auth_nous import _validate_nous_inference_url_from_network + access_token = exchanged["access_token"] + claims = _decode_jwt_claims(access_token) + now = datetime.now(timezone.utc) + exp = claims.get("exp") + if isinstance(exp, (int, float)): + expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc) + else: + expires_at = now + timedelta(seconds=int(exchanged.get("expires_in") or 900)) + inference_url = _validate_nous_inference_url_from_network(exchanged.get("inference_base_url")) + scope = claims.get("scope") or claims.get("scp") or state.get("scope") + if isinstance(scope, (list, tuple)): + scope = " ".join(str(s) for s in scope) + state.update( + access_token=access_token, token_type="Bearer", scope=scope, + obtained_at=now.isoformat(), expires_at=expires_at.isoformat(), + expires_in=max(0, int((expires_at - now).total_seconds())), + account_tier=str(claims.get("account_tier") or ANON_ACCOUNT_TIER)) + if inference_url: + state["inference_base_url"] = inference_url + for key in ("user_id", "org_id"): + if exchanged.get(key): + state[key] = exchanged[key] + state.pop("refresh_token", None) + + +def _portal_base_url() -> str: + from hermes_cli.auth_nous import _nous_portal_env_override + return (_nous_portal_env_override() or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + + +def _shared_identity_key(state: Any) -> Optional[str]: + """Stable identity of a Nous credential: the anon_ token for a guest, the refresh token for an + account. Used to decide whether two stores hold the SAME identity.""" + if not isinstance(state, dict): + return None + return state.get("anon_token") if is_guest_state(state) else state.get("refresh_token") + + +def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) -> Dict[str, Any]: + """Mint under the caller's locks. The identity is persisted as soon as ``create`` succeeds, BEFORE + the exchange: a 429 or timeout on the exchange must not lose a credential NAS still honours (the + next attempt exchanges the stored one instead of minting again).""" + from hermes_cli.auth import _save_provider_state, _save_auth_store + from hermes_cli.auth_nous import _write_shared_nous_state + minted = mint_guest(client, portal) + state: Dict[str, Any] = { + "auth_method": ANON_AUTH_METHOD, "account_tier": ANON_ACCOUNT_TIER, + "anon_token": minted["token"], "client_id": ANON_CLIENT_ID, + "portal_base_url": portal.rstrip("/"), + "user_id": minted.get("user_id"), "org_id": minted.get("org_id"), + "idle_ttl_days": minted.get("idle_ttl_days"), + } + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + _write_shared_nous_state(state) + logger.info("Nous free tier ready (identity minted)") + return state + + +_background_lock = threading.Lock() +_background_started = False +# Per-process memos for the blocking path. ``_mint_failed``: one failed mint is enough for a process +# (several bootstrap sites call in sequence; a 429 or a closed gate must not be hit twice); +# ``clear_dead_guest`` resets it because a retired credential is a reason to mint again. +# ``_forced_new_done``: ``HERMES_FORCE_GUEST=new`` re-mints once per process, not on every resolution. +_mint_failed = False +_forced_new_done = False + + +def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, Any]: + """The lifecycle body, run under profile lock THEN shared lock (the documented order). + + 1. The shared store is the identity of record for this Hermes root. If it holds an identity + that differs from the profile's, the profile adopts it (a stale guest never outlives a + sibling profile's sign-in, and never overwrites it). + 2. Otherwise the profile's own identity stands. + 3. Nothing anywhere: mint, persisting the credential before exchanging it. + ``force == "new"`` skips 1 and 2. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, + _save_provider_state, _resolve_verify) + from hermes_cli.auth_nous import ( + _nous_http_client, _nous_shared_store_lock, _read_shared_nous_state, _write_shared_nous_state) + portal = _portal_base_url() + with _auth_store_lock(): + auth_store = _load_auth_store() + profile_state = _load_provider_state(auth_store, "nous") + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds, 5.0)): + if force != "new": + shared = _read_shared_nous_state() + if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state): + state = dict(shared) + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + logger.debug("Nous identity adopted from the shared store") + return state + if profile_state: + if not shared: + _write_shared_nous_state(profile_state) + return profile_state + verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None) + with _nous_http_client(timeout_seconds, verify) as client: + return _mint_locked(client, portal, auth_store) + + +def ensure_portal_identity(*, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: + """Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared + store has none. Returns the ``providers.nous`` state, or None (disabled / non-blocking / failed). + + Order: ``nous.guest`` gate -> reconcile with the shared store -> mint. Locks are taken profile + first, then shared, matching every other Nous path. Non-blocking mode runs on a daemon thread + and returns None immediately; a failure there is logged at DEBUG (the guest is a fallback; a + fallback failing is not an error). + """ + global _mint_failed, _forced_new_done + if not guest_enabled(): + return None + force = force_guest_mode() + if force == "new" and _forced_new_done: + force = "1" + if _mint_failed and force != "new" and not current_nous_state(): + return None # this process already tried and failed; do not hammer the portal + + if blocking: + try: + result = _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + except Exception: + _mint_failed = True + raise + if force == "new": + _forced_new_done = True + return result + + global _background_started + with _background_lock: + if _background_started: + return None + _background_started = True + + def _run() -> None: + global _background_started + try: + _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + except Exception as exc: + logger.debug("Nous free tier background setup skipped: %s", exc) + # A transient failure must not consume the process's only attempt: release the latch + # so a later non-blocking call can try again (still one setup in flight at a time). + with _background_lock: + _background_started = False + + try: + threading.Thread(target=_run, name="nous-guest-identity", daemon=True).start() + except Exception as exc: # thread limit / interpreter shutdown: release so a later call can retry + with _background_lock: + _background_started = False + logger.debug("Nous free tier background setup could not start: %s", exc) + return None + + +def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: + """Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place. + + The portal URL is the resolver's canonical one (env override, else the validated stored URL, + else the default), never a raw stored value on its own. + Raises :class:`AnonCredentialDead` when NAS no longer knows the credential; the caller owns + re-minting (:func:`ensure_portal_identity` after :func:`clear_dead_guest`). + """ + anon_token = state.get("anon_token") + if not isinstance(anon_token, str) or not anon_token: + raise AnonCredentialDead("Nous free-tier credential is missing.", code="anon_credential_dead") + from hermes_cli.auth import _nous_portal_base_url + apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token)) + + +def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None: + """Drop a dead guest so the next need re-mints. + + Only the identity that actually failed is removed: a stale profile whose credential NAS rejected + must not erase a sibling profile's newer sign-in or replacement guest from the shared store. When + *dead_token* is None the profile's current guest is treated as the failed one. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_section) + from hermes_cli.auth_nous import _clear_shared_nous_state, _nous_shared_store_lock, _read_shared_nous_state + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + if is_guest_state(state): + token = dead_token or state.get("anon_token") + if state.get("anon_token") == token: + _store_section(auth_store, "providers").pop("nous", None) + _store_section(auth_store, "credential_pool").pop("nous", None) + if auth_store.get("active_provider") == "nous": + auth_store["active_provider"] = None + _save_auth_store(auth_store) + else: + token = dead_token + with _nous_shared_store_lock(): + shared = _read_shared_nous_state() + if token and is_guest_state(shared) and shared.get("anon_token") == token: + _clear_shared_nous_state(reason) + global _mint_failed + _mint_failed = False + logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason) + + +# One-time CLI notice: an install whose inference is carried by an explicit provider learns once that +# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it +# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more. +GUEST_NOTICE_FLAG = "guest_notice_shown" +FREE_TIER_AVAILABLE_NOTICE = ( + "Free Nous inference and connectors are now available. " + "`hermes model` to try them, `hermes auth upgrade` to sign in.") + + +def guest_notice_pending() -> bool: + """True when a guest identity exists and the one-time availability notice has not been shown.""" + state = current_nous_state() + return is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG)) + + +def mark_guest_notice_shown() -> bool: + """Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it). + + Returns True when a flag was written; False when there is no guest to mark.""" + from hermes_cli.auth import ( + _auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store, + _store_section) + with _provider_state_transaction("nous") as (auth_store, state, source_path): + if not is_guest_state(state) or source_path is None: + return False + if state.get(GUEST_NOTICE_FLAG): + return True + state = dict(state) + state[GUEST_NOTICE_FLAG] = True + if _same_path(source_path, _auth_file_path()): + _store_section(auth_store, "providers")["nous"] = state + _save_auth_store(auth_store) + else: + source_store = _load_auth_store(source_path) + _store_section(source_store, "providers")["nous"] = state + _save_auth_store(source_store, target_path=source_path) + return True + + +# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors --------- +# +# Wire: the normal device-code flow, with a promotion intent registered on NAS BETWEEN the code +# request and the token poll (``POST /api/anonymous/promotion-intent {token, user_code, device_code}``). +# NAS then transfers the guest's connectors into whichever account approves that device code. We +# watch ``POST /api/anonymous/promotion-status {claim_code}`` until it leaves ``pending``; only a +# ``completed`` promotion is followed by the token grant, which ``persist_nous_credentials`` writes +# over the guest singleton and the shared store. The server never reports expiry: our own +# ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim. + +UPGRADE_START = "Sign in to keep your connectors and unlock more." +UPGRADE_ALREADY_SIGNED_IN = "Already signed in." +UPGRADE_DO_NOT_SHARE = "Do not share this code." +UPGRADE_TIMED_OUT = "Sign-in timed out; run the command again." +UPGRADE_NOT_COMPLETED = "Sign-in did not complete; run the command again." +UPGRADE_UNAVAILABLE = "The free tier is not available right now; run `hermes auth add nous` to sign in." +UPGRADE_REASON_COPY = { + "user_declined": "Sign-in was rejected in the browser.", + "superseded": "A newer sign-in code replaced this one.", + "account_retired": "This free-tier identity was already used or expired; a new one is set up on next use.", + "account_not_anonymous": "This free-tier identity was already used or expired; a new one is set up on next use.", + "account_busy": "The transfer could not run; run the command again.", +} +_RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"}) +UPGRADED_AUTH_METHOD = "oauth_device_code" + + +def register_promotion_intent( + client: httpx.Client, portal_base_url: str, anon_token: str, *, user_code: str, device_code: str, +) -> Dict[str, Any]: + """``POST /api/anonymous/promotion-intent`` -> ``{claim_code, claim_url, expires_in, interval}``.""" + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-intent", headers=_anon_headers(), + json={"token": anon_token, "user_code": user_code, "device_code": device_code}) + payload = _raise_for_anon_status(response, action="sign-in") + if not isinstance(payload.get("claim_code"), str) or not payload["claim_code"]: + raise _anon_err("Nous free tier sign-in returned no transfer code.", "anon_server_error") + return payload + + +def _retry_after_seconds(response: httpx.Response, default: float) -> float: + raw = (response.headers.get("retry-after") or "").strip() + try: + return max(0.0, float(raw)) if raw else default + except ValueError: + return default + + +def wait_for_promotion( + client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int, +) -> Dict[str, Any]: + """Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out. + + Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours + ``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`. + """ + deadline = time.monotonic() + max(1, int(expires_in)) + wait = max(0, int(interval)) + while time.monotonic() < deadline: + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(), + json={"claim_code": claim_code}) + if response.status_code == 429: + time.sleep(min(_retry_after_seconds(response, default=max(1, wait)), max(0.0, deadline - time.monotonic()))) + continue + payload = _raise_for_anon_status(response, action="sign-in") + if str(payload.get("status") or "unknown") != "pending": + return payload + time.sleep(wait) + return {"status": "timeout"} + + +def _account_state_from_token( + token_data: Dict[str, Any], *, portal_base_url: str, client_id: str, scope: Optional[str], verify: Any, + timeout_seconds: float, +) -> Dict[str, Any]: + """The ``providers.nous`` shape for the signed-in account (same fields the device-code login writes).""" + from hermes_cli.auth import PROVIDER_REGISTRY, _coerce_ttl_seconds, _optional_base_url, _tls_state_from_verify + from hermes_cli.auth_nous import _NOUS_EMPTY_AGENT_KEY_FIELDS, _iso_after, refresh_nous_oauth_from_state + now = datetime.now(timezone.utc) + ttl = _coerce_ttl_seconds(token_data.get("expires_in", 0)) + inference_url = ( + _optional_base_url(token_data.get("inference_base_url")) + or PROVIDER_REGISTRY["nous"].inference_base_url.rstrip("/")) + state = { + "portal_base_url": portal_base_url, "inference_base_url": inference_url, + "client_id": client_id, "scope": token_data.get("scope") or scope, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), + "obtained_at": now.isoformat(), "expires_at": _iso_after(now, ttl), "expires_in": ttl, + "tls": _tls_state_from_verify(verify), **_NOUS_EMPTY_AGENT_KEY_FIELDS} + state = refresh_nous_oauth_from_state(state, timeout_seconds=timeout_seconds, force_refresh=False) + state["auth_method"] = UPGRADED_AUTH_METHOD + return state + + +def _print_promotion_outcome(outcome: Dict[str, Any]) -> None: + status = str(outcome.get("status") or "unknown") + reason = str(outcome.get("reason") or "") + if status == "timeout": + print(UPGRADE_TIMED_OUT) + return + print(UPGRADE_REASON_COPY.get(reason, UPGRADE_NOT_COMPLETED)) + if reason in _RETIRED_REASONS: + clear_dead_guest("retired") + + +def upgrade_guest(args) -> int: + """``hermes auth upgrade``: sign in with a Nous account, transferring the free tier's connectors. + + Returns 0 on success (or when already signed in), 1 otherwise. Never persists anything unless the + promotion completed AND the token grant succeeded. + """ + from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_verify + from hermes_cli.auth_device_flow import ( + _is_remote_session, _poll_for_token, _print_device_code_instructions, _request_device_code) + from hermes_cli.auth_nous import _nous_http_client, persist_nous_credentials + timeout_seconds = float(getattr(args, "timeout", None) or 15.0) + open_browser = not getattr(args, "no_browser", False) and not _is_remote_session() + state = current_nous_state() + if state and not is_guest_state(state): + print(UPGRADE_ALREADY_SIGNED_IN) + return 0 + if not state: + try: + state = ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) + except AuthError as exc: + print(f"{UPGRADE_UNAVAILABLE} ({exc})") + return 1 + if not is_guest_state(state): + print(UPGRADE_UNAVAILABLE) + return 1 + anon_token = str(state.get("anon_token") or "") + portal = (state.get("portal_base_url") or _portal_base_url()).rstrip("/") + pconfig = PROVIDER_REGISTRY["nous"] + client_id, scope = pconfig.client_id, pconfig.scope + verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None) + print(UPGRADE_START) + try: + with _nous_http_client(timeout_seconds, verify) as client: + device = _request_device_code(client, portal, client_id, scope) + intent = register_promotion_intent( + client, portal, anon_token, user_code=str(device["user_code"]), + device_code=str(device["device_code"])) + # The browser leg is the consent page for THIS sign-in (claim_url), not the generic + # device page: it shows both identities and the Move button. Relative paths are + # portal-relative. + claim_url = str(intent.get("claim_url") or "") + if claim_url.startswith("/"): + claim_url = f"{portal}{claim_url}" + _print_device_code_instructions( + claim_url or str(device["verification_uri_complete"]), str(intent["claim_code"]), + open_browser=open_browser, swallow_open_errors=True) + print(f" {UPGRADE_DO_NOT_SHARE}") + expires_in = min(int(device["expires_in"]), int(intent.get("expires_in") or device["expires_in"])) + interval = int(intent.get("interval") or device.get("interval") or 5) + print("Waiting for sign-in...") + outcome = wait_for_promotion(client, portal, intent["claim_code"], expires_in=expires_in, interval=interval) + if str(outcome.get("status")) != "completed": + _print_promotion_outcome(outcome) + return 1 + token_data = _poll_for_token( + client=client, portal_base_url=portal, client_id=client_id, + device_code=str(device["device_code"]), expires_in=max(1, expires_in), poll_interval=interval) + account_state = _account_state_from_token( + token_data, portal_base_url=portal, client_id=client_id, scope=scope, verify=verify, + timeout_seconds=timeout_seconds) + except AnonCredentialDead: + print(UPGRADE_REASON_COPY["account_retired"]) + clear_dead_guest("retired") + return 1 + except TimeoutError: + print(UPGRADE_TIMED_OUT) + return 1 + except KeyboardInterrupt: + print("\nSign-in cancelled.") + return 130 + except Exception as exc: + print(f"Sign-in failed: {exc}") + return 1 + persist_nous_credentials(account_state) + email = str(outcome.get("account_email") or "").strip() + print(f"Signed in as {email}. Your connectors are kept." if email else "Signed in. Your connectors are kept.") + return 0 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e0cd0b81ca..74fc77277b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1371,6 +1371,10 @@ def _logged_in_oauth_active_provider() -> Optional[str]: """auth.json ``active_provider`` when it is a registry provider that reports logged in.""" try: _maybe = _load_auth_store().get("active_provider") + if _maybe == "nous": + from hermes_cli.anon_auth import guest_enabled, has_guest + if has_guest() and not guest_enabled(): + return None # nous.guest: false — the free tier is off, so a guest is not a login if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"): return _maybe except Exception as e: @@ -1490,6 +1494,15 @@ def resolve_provider( return "bedrock" except ImportError: pass # boto3 not installed + # Nothing configured at all: set up the Nous free tier (blocking, short timeout). Success writes + # ``active_provider: nous``, which the OAuth rung above then picks up on every later call; + # failure of this fallback is not an error and falls through to the guidance below. + try: + from hermes_cli.anon_auth import ensure_portal_identity + if ensure_portal_identity(blocking=True) is not None: + return "nous" + except Exception as exc: + logger.debug("free tier setup during provider resolution skipped: %s", exc) raise AuthError( "No inference provider configured. Run 'hermes model' to choose a " "provider and model, or set an API key (OPENROUTER_API_KEY, " @@ -1628,6 +1641,21 @@ def resolve_nous_access_token( lock_timeout = max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) with _nous_shared_store_lock(timeout_seconds=lock_timeout): + from hermes_cli.anon_auth import is_guest_state, refresh_guest_state + if is_guest_state(state): + # Guest seam: the anon_ credential is the identity; a first use has no access token + # yet and an expired one is re-exchanged. No refresh token, no quarantine. + access_token = state.get("access_token") + if isinstance(access_token, str) and access_token and not _is_expiring( + state.get("expires_at"), refresh_skew_seconds): + return _memo(access_token) + with httpx.Client(timeout=httpx.Timeout(timeout_seconds or 15.0), + headers={"Accept": "application/json"}, verify=verify) as client: + refresh_guest_state(state, client) + persist() + _write_shared_nous_state(state) + return _memo(state["access_token"]) + merged_shared = _merge_shared_nous_oauth_state(state) access_token = state.get("access_token") refresh_token = state.get("refresh_token") @@ -2217,11 +2245,21 @@ def logout_command(args) -> None: if not target: print("No provider is currently logged in.") return + if target == "nous": + from hermes_cli.anon_auth import FREE_TIER_NOT_SIGNED_IN, is_guest_state + if is_guest_state(get_provider_auth_state("nous")): + # Free tier is not a login; there is nothing to log out of and nothing is cleared. + print(FREE_TIER_NOT_SIGNED_IN) + return should_reset_config = _should_reset_config_provider_on_logout(target) provider_name = get_auth_provider_display_name(target) if not (clear_provider_auth(target) or should_reset_config): print(f"No auth state found for {provider_name}.") return + if target == "nous": + # A profile logout must not be re-adopted from the cross-profile store on the next boot. + from hermes_cli.auth_nous import _clear_shared_nous_state + _clear_shared_nous_state("logout") if should_reset_config: _reset_config_provider() print(f"Logged out of {provider_name}.") diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 0b51aea7a7..78c3303707 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -420,6 +420,17 @@ def auth_priority_command(args) -> None: _report_priority(provider, pool, moved, requested, "Set", "to") +def _free_tier_lines() -> tuple[str, str]: + """The two-line free-tier rendering shared by every auth display surface (R-USR-1).""" + from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT + return f"{FREE_TIER_LABEL} · {GUEST_MODEL}", UPGRADE_HINT + + +def _is_free_tier_entry(entry) -> bool: + from hermes_cli.anon_auth import is_guest_state + return is_guest_state(getattr(entry, "extra", None)) + + def auth_list_command(args) -> None: provider_filter = _normalize_provider(getattr(args, "provider", "") or "") if provider_filter: @@ -436,6 +447,13 @@ def auth_list_command(args) -> None: if not entries: continue current = pool.peek() + if provider == "nous" and all(_is_free_tier_entry(e) for e in entries): + # The free tier is not a credential the user added; never list it as one. + label, hint = _free_tier_lines() + print(f"{provider}: {label}") + print(f" {hint}") + print() + continue print(f"{provider} ({len(entries)} credentials):") for idx, entry in enumerate(entries, start=1): marker = "← " if current is not None and entry.id == current.id else " " @@ -562,6 +580,12 @@ def auth_status_command(args) -> None: load_pool(provider) # runs the forked-grant heal first so the report reflects the consolidated grant status = auth_mod.get_auth_status(provider) _print_oauth_heal_notices() + if status.get("free_tier"): + # Free tier: not an account login, so no account fields; point at the upgrade path. + label, hint = _free_tier_lines() + print(f"{provider}: {label}") + print(f" {hint}") + return if not status.get("logged_in"): reason = status.get("error") print(f"{provider}: logged out" + (f" ({reason})" if reason else "")) @@ -756,10 +780,18 @@ def _interactive_strategy() -> None: print(f"Set {provider} strategy to: {strategy}") +def auth_upgrade_command(args) -> None: + """``hermes auth upgrade``: sign the free tier into a Nous account, keeping its connectors.""" + from hermes_cli.anon_auth import upgrade_guest + code = upgrade_guest(args) + if code: + raise SystemExit(code) + + _AUTH_ACTIONS = { "add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command, "reset": auth_reset_command, "priority": auth_priority_command, "refresh": auth_refresh_command, "status": auth_status_command, - "logout": auth_logout_command, + "logout": auth_logout_command, "upgrade": auth_upgrade_command, "spotify": auth_spotify_command} diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 7289291d13..faef278718 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -103,7 +103,10 @@ def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None: # else would leak. Consulted only for URLs from the NETWORK side (Portal refresh responses); # the NOUS_INFERENCE_BASE_URL env override bypasses it (documented dev/staging escape hatch, the # user set it themselves). -_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({"inference-api.nousresearch.com"}) +_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ + "inference-api.nousresearch.com", + # Free-tier (anonymous) host: serves the single ``nous/welcome`` model. + "welcome-api.nousresearch.com"}) def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: @@ -329,7 +332,9 @@ def _shared_lock_timeout(timeout_seconds: float) -> float: # OAuth fields mirrored between a profile's Nous state and the shared cross-profile store. _NOUS_SHARED_STATE_KEYS = ( "access_token", "refresh_token", "token_type", "scope", "client_id", "portal_base_url", - "inference_base_url", "obtained_at", "expires_at") + "inference_base_url", "obtained_at", "expires_at", + # Guest (``auth_method: anonymous``) identity: the ``anon_`` credential is the refresh material. + "auth_method", "account_tier", "anon_token", "user_id", "org_id") def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: @@ -338,7 +343,7 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: shared = _read_shared_nous_state() or {} shared_refresh = shared.get("refresh_token") if not _nonempty_str(shared_refresh): - return False + return False # a free-tier identity has no refresh token; nothing to merge into an OAuth state shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0 local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0 refresh_changed = shared_refresh.strip() != str(state.get("refresh_token") or "").strip() @@ -360,7 +365,9 @@ def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]: "client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID, "portal_base_url": src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, "inference_base_url": src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, - "obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at")} + "obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"), + **{k: src[k] for k in ("auth_method", "account_tier", "anon_token", "user_id", "org_id") + if src.get(k) not in (None, "")}} def _write_shared_nous_state(state: Dict[str, Any]) -> None: @@ -370,8 +377,10 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: """ from hermes_cli.auth import _nonempty_str, _write_private_file_atomic refresh_token = state.get("refresh_token") - # No refresh_token = nothing worth sharing across profiles - if not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))): + # Nothing worth sharing without refresh material: an OAuth refresh_token (with its access token), + # or a guest's anon_ credential, which is the whole identity and may not have been exchanged yet. + is_guest = _nonempty_str(state.get("anon_token")) + if not is_guest and not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))): return shared = { "_schema": 1, **_nous_shared_shape(state), @@ -407,8 +416,8 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]: return None if not isinstance(payload, dict): return None - has_tokens = ( - _nonempty_str(payload.get("refresh_token")) and _nonempty_str(payload.get("access_token"))) + has_tokens = _nonempty_str(payload.get("anon_token")) or ( + _nonempty_str(payload.get("access_token")) and _nonempty_str(payload.get("refresh_token"))) return payload if has_tokens else None @@ -919,6 +928,17 @@ class _NousRuntimeResolve: def ensure_usable_access_token(self, client: httpx.Client) -> None: """Merge from the shared store / refresh until the access token is a usable invoke JWT.""" + from hermes_cli.anon_auth import is_guest_state, refresh_guest_state + if is_guest_state(self.state): + # Guest seam: the anon_ credential is the refresh material; re-exchange instead of + # redeeming a rotating refresh token. Quarantine never applies to a guest. + if self.force_refresh or self.invoke_jwt_status() is not None: + refresh_guest_state(self.state, client) + self.access_token = self.state["access_token"] + self.stored_inference_base_url = self.state.get("inference_base_url") or self.stored_inference_base_url + self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url + self.persist("guest_exchange") + return if not self.has_access_token(): with self.shared_lock(): if self.merge_shared(): @@ -944,6 +964,30 @@ def resolve_nous_runtime_credentials( stale_access_token: Optional[str] = None) -> Dict[str, Any]: """Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock). + A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and a new + identity is set up once, transparently -- the one client rule covering both reap and claim. + """ + from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity + try: + return _resolve_nous_runtime_credentials( + timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle, + force_refresh=force_refresh, stale_access_token=stale_access_token) + except AnonCredentialDead: + from hermes_cli.auth import get_provider_auth_state + dead = get_provider_auth_state("nous") or {} + clear_dead_guest("anon_credential_dead", dead_token=dead.get("anon_token")) + if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) is None: + raise + return _resolve_nous_runtime_credentials( + timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) + + +def _resolve_nous_runtime_credentials( + *, timeout_seconds: float = 15.0, insecure: Optional[bool] = None, + ca_bundle: Optional[str] = None, force_refresh: bool = False, + stale_access_token: Optional[str] = None) -> Dict[str, Any]: + """Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock). + ``stale_access_token`` is the bearer that just failed upstream (401): with ``force_refresh``, the refresh POST is skipped if the store (re-read under the lock) already holds a *different* usable token — a peer won the rotation; adopt it rather than invalidate a sibling's token. @@ -1042,7 +1086,9 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]: def _nous_status_from_state( state: Dict[str, Any], *, logged_in: bool, source: str) -> Dict[str, Any]: """Auth-store-backed Nous status snapshot (shared by the live and refresh-free variants).""" + from hermes_cli.anon_auth import is_guest_state access_token = state.get("access_token") + account_tier = state.get("account_tier") return { "logged_in": logged_in, "portal_base_url": state.get("portal_base_url"), "inference_base_url": state.get("inference_base_url"), @@ -1050,7 +1096,10 @@ def _nous_status_from_state( "agent_key_expires_at": state.get("agent_key_expires_at"), "has_refresh_token": bool(state.get("refresh_token")), "access_token": access_token, "inference_credential_present": bool(access_token or state.get("agent_key")), - "credential_source": "auth_store", "source": source} + "credential_source": "auth_store", "source": source, + # Free tier: display surfaces render it with the free-tier copy, never as an account login. + "account_tier": account_tier if isinstance(account_tier, str) else None, + "free_tier": is_guest_state(state)} def _compute_nous_auth_status() -> Dict[str, Any]: @@ -1394,8 +1443,11 @@ def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any] auth state when the user accepted and the import succeeded, else None. """ from hermes_cli.auth import _prompt_yes_no, _read_shared_nous_state + from hermes_cli.anon_auth import is_guest_state shared = _read_shared_nous_state() - if not shared: + if not shared or is_guest_state(shared): + # A free-tier identity is not an OAuth credential to import; a real sign-in replaces it + # (persist_nous_credentials overwrites the singleton and the shared store). return None try: shared_path = _nous_shared_store_path() diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 0c24f4c214..1dc6432bdf 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -198,6 +198,18 @@ class CLIAgentSetupMixin: api_key = runtime.get("api_key") base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") + if resolved_provider != "nous": + # Explicit provider carries inference; the free tier still sets itself up (background, + # nothing waits) so connectors have a bearer. No-op when an identity exists or the + # free tier is off. + try: + from hermes_cli.anon_auth import ensure_portal_identity + ensure_portal_identity(blocking=False) + except Exception as exc: + logger.debug("free tier background setup skipped: %s", exc) + # The mint above may land after this turn, so the one-time "free tier is here" notice is + # checked on every credential resolve and printed the first time an identity is seen. + self._maybe_print_free_tier_available_notice() resolved_routing = ( resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), list(runtime.get("args") or [])) @@ -264,6 +276,20 @@ class CLIAgentSetupMixin: self._active_agent_route_signature = None return True + def _maybe_print_free_tier_available_notice(self) -> None: + """One-time notice for installs whose inference is carried by an explicit provider: the free + tier (inference + connectors) now exists. Printed the first time an identity is present, then + flagged on that identity so it never repeats. Never blocks or raises.""" + from cli import logger + try: + from hermes_cli import anon_auth + if not anon_auth.guest_notice_pending(): + return + self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]") + anon_auth.mark_guest_notice_shown() + except Exception as exc: + logger.debug("free tier availability notice skipped: %s", exc) + def _resolve_fallback_runtime(self, primary_exc): """Primary provider resolution failed: on an AuthError try each fallback entry in order and switch the CLI's requested_provider/model to the first that resolves. diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 665a458135..d2902f298b 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2335,6 +2335,10 @@ DEFAULT_CONFIG = { # 14-20% of consecutive calls in concurrent tool loops (measured 2026-09-06; # NousResearch/api#227), so chat is the default until that is fixed. "anthropic_wire": "chat", + # Nous free tier: with no other provider configured, Hermes sets up a free Nous identity on + # first use (inference on nous/welcome + connectors) and offers `hermes auth upgrade` to + # sign in. false turns the free tier off entirely: nothing is set up and nothing is used. + "guest": True, }, # Google Vertex AI (Gemini). Auth is OAuth2 from a service-account JSON or ADC, NOT an API key; # the credential path lives in .env (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS). @@ -2411,6 +2415,11 @@ def _base_url(name, prompt_name=None): OPTIONAL_ENV_VARS = { # ── Provider (handled in provider selection, not shown in checklists) ── "NOUS_BASE_URL": _base_url("Nous Portal"), + "HERMES_ANON_API_SECRET": _env( + "Shared secret for the Nous free-tier sign-up endpoints while they are in their gated " + "integration phase (not needed once the gate is removed)", + "Nous free-tier shared secret (leave empty unless given one)", password=True, + category="provider", advanced=True), "OPENROUTER_API_KEY": _env("OpenRouter API key (for vision, web scraping helpers, and MoA)", "OpenRouter API key", url="https://openrouter.ai/keys", password=True, tools=["vision_analyze"], category="provider", advanced=True), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 24988010e5..ca0cd6f906 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -968,6 +968,16 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: from hermes_cli.config import DEFAULT_CONFIG, get_env_path, get_hermes_home, load_config from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status + # Dev lever: HERMES_FORCE_GUEST makes the free tier the answer regardless of what else is + # configured ("new" also re-mints once per process). Kept ahead of every other check on purpose. + try: + from hermes_cli.anon_auth import ensure_portal_identity, force_guest_mode + if force_guest_mode(): + return ensure_portal_identity(blocking=True) is not None + except Exception as exc: + logger.debug("forced free tier setup failed: %s", exc) + return False + cfg = load_config() model_cfg = cfg.get("model") _model_name = model_cfg if isinstance(model_cfg, str) else "" @@ -1043,6 +1053,14 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: except Exception: pass + # Nothing explicit anywhere: the Nous free tier counts as configured once its identity exists. + # Setting it up here (blocking, short timeout) is the first-run path for a fresh install; any + # failure means "not configured" and the setup guard takes over as before. + try: + from hermes_cli.anon_auth import ensure_portal_identity + return ensure_portal_identity(blocking=True) is not None + except Exception as exc: + logger.debug("free tier setup on first run skipped: %s", exc) return False diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py index 863fbf343a..6e18cb4035 100644 --- a/hermes_cli/main_provider_setup.py +++ b/hermes_cli/main_provider_setup.py @@ -816,7 +816,16 @@ def _build_provider_picker_rows(config: dict, active: str, provider_labels: dict _add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group) else: slug = row["slug"] - _add(slug, canonical_descs.get(slug, provider_labels.get(slug, slug)), [], bool(active) and slug == active) + label = canonical_descs.get(slug, provider_labels.get(slug, slug)) + if slug == "nous": + # Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide + # when nous.guest is off, untouched for a real account. + from hermes_cli.model_switch_providers import _free_tier_nous_row + tier_row = _free_tier_nous_row({"name": label, "models": []}) + if tier_row is None: + continue + label = tier_row["name"] + _add(slug, label, [], bool(active) and slug == active) for key, provider_info in custom_provider_map.items(): saved_model = provider_info.get("model", "") diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index f6e7108c15..a3dbbe35fa 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -289,6 +289,21 @@ def _model_flow_nous(config, current_model="", args=None): # instead of the hundreds returned by the live /models endpoint. from hermes_cli.models import check_nous_free_tier, get_curated_nous_model_ids from hermes_cli.models_pricing import get_pricing_for_provider + from hermes_cli.model_switch_providers import _free_tier_nous_row + tier_row = _free_tier_nous_row({"name": "Nous Portal", "models": []}) + if tier_row is None: + print("Nous free tier is switched off (nous.guest: false); sign in with `hermes auth upgrade` to use Nous models.") + return + if tier_row["models"]: + # Free-tier identity: the welcome host serves the single pinned model; no Portal catalog, + # pricing, or account lookups apply. + creds = _nous_verified_credentials() + if creds is None: + return + selected = tier_row["models"][0] + _nous_persist_selection(selected, creds) + print(f"Default model set to: {selected} (via {tier_row['name']})") + return model_ids = get_curated_nous_model_ids() if not model_ids: print("No curated models available for Nous Portal.") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index ac49fb7e5a..69b40092b1 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1220,6 +1220,14 @@ def _route_from_model_input(st: _Switch) -> Optional[ModelSwitchResult]: # Steps d.5 / e only apply while the request is still unrouted on the current provider. if st.resolved_alias or resolved_in_current_catalog or st.target_provider != current_provider: return None + if current_provider == "nous": + # The welcome host serves nous/welcome only; a model outside it needs an account or a key. + # Never hop to another provider on the user's behalf here (there is no key to hop to). + from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host + if route_is_welcome_host(st.current_base_url) and st.new_model != GUEST_MODEL: + return st.fail( + f"{st.new_model} needs a Nous account or an API key. " + "Run `hermes auth upgrade` to sign in, or `hermes model` to pick another provider.") config_routed = _route_configured_provider(st) # d.5 — deliberately NOT gated on ``not is_custom`` if isinstance(config_routed, ModelSwitchResult): return config_routed diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 00014ab4e2..8a307a738d 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -437,6 +437,27 @@ def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list: return model_ids +def _free_tier_nous_row(row: dict) -> dict | None: + """The one free-tier rule for a Nous picker row, shared by every row builder. + + ``row`` carries at least ``name`` and ``models``. A guest identity carrying inference turns + it into "Nous · free tier" with the single model ``nous/welcome`` (the welcome host serves + nothing else). A guest that ``nous.guest: false`` has switched off yields ``None``: no Nous + row at all, since there is nothing selectable. A real account (or no Nous state) passes the + row through untouched. Builders that compute the full catalog lazily should pass + ``models=[]`` and only compute when the returned row still has no models.""" + from hermes_cli import anon_auth + if not anon_auth.has_guest(): + return row + if not anon_auth.guest_enabled(): + return None + out = dict(row) + out["name"] = anon_auth.FREE_TIER_LABEL + out["models"] = [anon_auth.GUEST_MODEL] + out["total_models"] = 1 + return out + + def _cap_models(model_ids: list, max_models: int | None, slug: str = "") -> list: """Apply ``max_models``; aggregators in ``_UNCAPPED_PICKER_PROVIDERS`` show everything.""" if slug in _UNCAPPED_PICKER_PROVIDERS or max_models is None: @@ -653,10 +674,16 @@ class _PickerBuild: def add_builtin_row( self, slug: str, name: str, is_current: bool, model_ids: list, source: str, *, uncapped_ok: bool = True, ) -> None: - self.results.append({ + row = { "slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, "models": _cap_models(model_ids, self.max_models, slug if uncapped_ok else ""), - "total_models": len(model_ids), "source": source}) + "total_models": len(model_ids), "source": source} + if slug == "nous": + # Free-tier identity: one row "Nous · free tier" / nous/welcome, or no row when + # nous.guest is off. Still marks the slug seen so a later lap cannot re-emit it. + row = _free_tier_nous_row(row) + if row is not None: + self.results.append(row) self.seen_slugs.add(slug.lower()) self.record_builtin_endpoint(slug) @@ -801,7 +828,11 @@ def _lap_overlay_rows(b: _PickerBuild, data: dict) -> None: elif overlay.auth_type == "aws_sdk": model_ids = _aws_live_or_curated_ids(hermes_slug, b.curated, hermes_slug, pid) elif hermes_slug == "nous": - model_ids = _nous_picker_model_ids(b.curated, b.force_fresh_nous_tier) + # A guest identity never needs the Portal catalog: add_builtin_row pins nous/welcome + # (or drops the row when nous.guest is off), so only a real account fetches. + tier_row = _free_tier_nous_row({"name": get_label(hermes_slug), "models": []}) + real_account = tier_row is not None and not tier_row["models"] + model_ids = _nous_picker_model_ids(b.curated, b.force_fresh_nous_tier) if real_account else [] else: model_ids = _live_or_curated_ids(hermes_slug, b.curated, hermes_slug, pid) b.add_builtin_row( diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index c3a0b92974..a7cb2f2cec 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -67,6 +67,15 @@ class NousToolAccessInfo: coverage: dict[str, bool] = field(default_factory=dict) +_ANON_ACCOUNT_TIER = "anonymous" +# Every billing / top-up / entitlement surface says exactly this for the free tier (R-USR-1). +FREE_TIER_NEEDS_ACCOUNT = "This needs a Nous account. Run `hermes auth upgrade`." + + +def _is_anonymous_tier(account_info: Optional["NousPortalAccountInfo"]) -> bool: + return account_info is not None and account_info.account_tier == _ANON_ACCOUNT_TIER + + @dataclass(frozen=True) class NousPortalAccountInfo: logged_in: bool @@ -93,11 +102,18 @@ class NousPortalAccountInfo: raw_claims: Optional[dict[str, Any]] = None raw_account: Optional[dict[str, Any]] = None error: Optional[str] = None + # NAS account tier claim; ``"anonymous"`` is the free tier (no Nous account behind it). + account_tier: Optional[str] = None @property def is_paid(self) -> bool: return self.paid_service_access is True + @property + def is_anonymous_tier(self) -> bool: + """The free tier: no Nous account, so no billing, credits, or entitlement to speak of.""" + return self.account_tier == _ANON_ACCOUNT_TIER + @property def is_free_tier(self) -> bool: return self.paid_service_access is False @@ -154,6 +170,8 @@ def format_nous_portal_entitlement_message( access doesn't fund it gets a neutral billing nudge, never an "exhausted" message. The pool-vs-paid distinction is never surfaced. """ + if _is_anonymous_tier(account_info): + return FREE_TIER_NEEDS_ACCOUNT billing_url = nous_portal_billing_url(account_info) if account_info is not None: @@ -209,6 +227,8 @@ def format_nous_portal_entitlement_message( def _no_paid_access_message(account_info: NousPortalAccountInfo, capability: str, billing_url: str) -> str: + if _is_anonymous_tier(account_info): + return FREE_TIER_NEEDS_ACCOUNT access = account_info.paid_service_access_info or NousPaidServiceAccessInfo() active, paid = access.has_active_subscription, access.active_subscription_is_paid labelled = ( @@ -478,6 +498,7 @@ def _info_from_valid_jwt( paid_service_access=paid_access, paid_service_access_info=access_info, tool_access=_tool_access_from_value(claims.get("tool_access")), raw_claims=dict(claims), + account_tier=_coerce_str(claims.get("account_tier")) or _coerce_str(state.get("account_tier")), ) @@ -506,6 +527,8 @@ def _info_from_account_payload( paid_service_access=paid_access, paid_service_access_info=access, tool_access=_tool_access_from_value(payload.get("tool_access")), raw_account=dict(payload), + account_tier=_coerce_str(payload.get("account_tier")) or _coerce_str(user.get("account_tier")) + or _coerce_str(state.get("account_tier")), ) diff --git a/hermes_cli/nous_auth_keepalive.py b/hermes_cli/nous_auth_keepalive.py index 580408e8b5..67d55e9a66 100644 --- a/hermes_cli/nous_auth_keepalive.py +++ b/hermes_cli/nous_auth_keepalive.py @@ -196,6 +196,15 @@ def start_nous_auth_keepalive( interval_seconds = _interval_seconds(interval_seconds) if interval_seconds <= 0: return None + # The free tier has no refresh token to keep alive: its access token is re-minted from the + # anon credential on demand by the request path, so a background refresher has nothing to do. + from hermes_cli.anon_auth import is_guest_state + try: + if is_guest_state(get_provider_auth_state("nous")): + logger.debug("Nous auth keepalive skipped: free tier has no refresh token") + return None + except Exception: + pass global _keepalive_thread with _keepalive_lock: if _keepalive_thread is not None and _keepalive_thread.is_alive(): diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 5b516c0640..389a22b347 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -46,8 +46,15 @@ def _cmd_status(args) -> int: except Exception: auth = {} logged_in = bool(auth.get("logged_in")) + free_tier = bool(auth.get("free_tier")) _heading("Nous Portal") - if logged_in: + if free_tier: + from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT + print(f" Auth: {color(f'{FREE_TIER_LABEL} · {GUEST_MODEL}', Colors.GREEN)}") + print(f" {UPGRADE_HINT}") + if auth.get("inference_base_url"): + print(f" API: {auth['inference_base_url']}") + elif logged_in: print(f" Auth: {color('✓ logged in', Colors.GREEN)}") print(f" Portal: {auth.get('portal_base_url') or DEFAULT_PORTAL_URL}") if auth.get("inference_base_url"): diff --git a/hermes_cli/status_auth.py b/hermes_cli/status_auth.py index afea2536f2..640f305a43 100644 --- a/hermes_cli/status_auth.py +++ b/hermes_cli/status_auth.py @@ -124,6 +124,17 @@ def _render_auth_providers(ctx): 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 diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index 70df5fec23..686f9ffb1e 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -59,6 +59,11 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_logout = auth_subparsers.add_parser( "logout", help="Log out a provider and clear stored auth state") auth_logout.add_argument("provider", help="Provider id") + auth_upgrade = auth_subparsers.add_parser( + "upgrade", help="Sign in with a Nous account, keeping your connectors") + auth_upgrade.add_argument( + "--no-browser", action="store_true", help="Do not auto-open a browser for sign-in") + auth_upgrade.add_argument("--timeout", type=float, help="Network timeout in seconds") auth_spotify = auth_subparsers.add_parser( "spotify", help="Authenticate Hermes with Spotify via PKCE") auth_spotify.add_argument( diff --git a/tests/gateway/test_free_tier_startup_notice.py b/tests/gateway/test_free_tier_startup_notice.py new file mode 100644 index 0000000000..036e26b617 --- /dev/null +++ b/tests/gateway/test_free_tier_startup_notice.py @@ -0,0 +1,102 @@ +"""Home-channel startup notice names the free tier only when a guest carries the gateway's inference.""" + +import base64 +import json +import time + +import pytest +from unittest.mock import AsyncMock + +import gateway.run as gateway_run +from gateway.config import HomeChannel, Platform +from gateway.platforms.base import SendResult +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store +from tests.gateway.restart_test_helpers import make_restart_runner + +FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _seed_nous(state: dict) -> None: + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = state + store["active_provider"] = "nous" + _save_auth_store(store) + + +def _guest_state() -> dict: + return {"auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001", + "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1"} + + +def _account_state() -> dict: + return {"auth_method": "oauth", "access_token": _jwt(client_id="hermes-cli", account_tier="pro"), + "refresh_token": "rt", "expires_at": "2999-01-01T00:00:00+00:00"} + + +@pytest.fixture +def nous_runner(tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + # Provider precedence gates the line and is answered from persisted state only (no network at boot). + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="home-1", name="Home") + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) + return runner, adapter + + +async def _startup_message(runner, adapter) -> str: + delivered = await runner._send_home_channel_startup_notifications() + assert delivered == {("telegram", "home-1", None)} + adapter.send.assert_called_once() + return adapter.send.call_args.args[1] + + +@pytest.mark.asyncio +async def test_guest_inference_adds_exactly_one_free_tier_line(nous_runner): + runner, adapter = nous_runner + _seed_nous(_guest_state()) + assert anon_auth.guest_carries_inference() + + message = await _startup_message(runner, adapter) + + lines = message.splitlines() + assert lines[0] == "♻️ Gateway online — Hermes is back and ready." + assert lines[1:] == [FREE_TIER_LINE] + assert "guest" not in message.lower() and "anonymous" not in message.lower() + + +@pytest.mark.asyncio +async def test_signed_in_account_keeps_the_plain_online_notice(nous_runner): + runner, adapter = nous_runner + _seed_nous(_account_state()) + assert not anon_auth.guest_carries_inference() + + message = await _startup_message(runner, adapter) + + assert message == "♻️ Gateway online — Hermes is back and ready." + + +@pytest.mark.asyncio +async def test_non_nous_provider_never_mentions_the_free_tier(nous_runner, monkeypatch): + runner, adapter = nous_runner + _seed_nous(_guest_state()) # identity exists for connectors, but inference is elsewhere + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") # an explicit key wins provider precedence + + message = await _startup_message(runner, adapter) + + assert message == "♻️ Gateway online — Hermes is back and ready." diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py new file mode 100644 index 0000000000..e1f4941b02 --- /dev/null +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -0,0 +1,418 @@ +"""Nous free tier core: identity lifecycle, token-acquisition seam, routing pin, opt-out. + +Behaviour contracts on the public seams (``ensure_portal_identity``, ``resolve_provider``, +``resolve_runtime_provider``, ``normalize_model_for_provider``), driven through a fake portal so +the wire contract is exercised, never mocked away. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path + +import httpx +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _load_auth_store, resolve_provider + +WELCOME = "https://welcome-api.nousresearch.com/v1" +PORTAL = "https://portal.example.test" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +class FakePortal: + """Minimal NAS anonymous surface. Records every call; scenarios flip its behaviour.""" + + def __init__(self): + self.calls: list[tuple[str, str]] = [] + self.dead_tokens: set[str] = set() + self.gate_closed = False + self.minted = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + self.calls.append((request.method, path)) + if path.startswith("/api/anonymous/") and not request.headers.get("x-anonymous-api-secret"): + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if self.gate_closed: + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if path == "/api/anonymous/create": + self.minted += 1 + return httpx.Response(201, json={"user_id": f"nas_user:{self.minted}", "org_id": "nas_org:1", + "token": f"anon_{self.minted:04d}", "idle_ttl_days": 14}) + if path == "/api/anonymous/token": + token = json.loads(request.content)["token"] + if token in self.dead_tokens: + return httpx.Response(404, json={"error": "unknown_token"}) + return httpx.Response(200, json={"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1", + "inference_base_url": WELCOME}) + return httpx.Response(500, json={"error": f"unexpected {path}"}) + + +@pytest.fixture +def portal(monkeypatch, tmp_path): + fake = FakePortal() + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL) + monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret") + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + from hermes_cli import auth_nous + + def _client(timeout_seconds, verify): + return httpx.Client(transport=httpx.MockTransport(fake.handler), base_url=PORTAL) + monkeypatch.setattr(auth_nous, "_nous_http_client", _client) + # resolve_nous_access_token builds its own client; route it through the fake too. + real_client = httpx.Client + + class _RoutedClient(real_client): + def __init__(self, *a, **kw): + kw.pop("verify", None) + kw["transport"] = httpx.MockTransport(fake.handler) + super().__init__(*a, **kw) + monkeypatch.setattr(httpx, "Client", _RoutedClient) + anon_auth._background_started = False + anon_auth._mint_failed = False + anon_auth._forced_new_done = False + # resolve_nous_access_token memoises the last token for 5 s across the process; a token minted + # by an earlier test must not be served to this one. + from hermes_cli import auth as auth_mod + monkeypatch.setattr(auth_mod, "_RESOLVE_TOKEN_CACHE", None) + return fake + + +def _write_config(monkeypatch, **nous): + home = Path(os.environ["HERMES_HOME"]) + (home / "config.yaml").write_text("nous:\n" + "".join(f" {k}: {str(v).lower()}\n" for k, v in nous.items())) + from hermes_cli import config as cfg_mod + for attr in ("_config_cache", "_cached_config"): + if hasattr(cfg_mod, attr): + monkeypatch.setattr(cfg_mod, attr, None, raising=False) + + +def _shared_store(tmp_path) -> dict: + p = tmp_path / "shared-store" / "nous_auth.json" + return json.loads(p.read_text()) if p.exists() else {} + + +class TestIdentityLifecycle: + def test_fresh_install_mints_once_and_is_the_active_provider(self, portal, tmp_path): + state = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(state) + assert "refresh_token" not in state + store = _load_auth_store() + assert store["active_provider"] == "nous" + assert anon_auth.is_guest_state(store["providers"]["nous"]) + assert _shared_store(tmp_path).get("anon_token") == state["anon_token"] + assert portal.minted == 1 + # Second call: identity exists, zero network. + before = len(portal.calls) + assert anon_auth.ensure_portal_identity(blocking=True)["anon_token"] == state["anon_token"] + assert len(portal.calls) == before + + def test_second_profile_under_same_root_adopts_from_shared_store(self, portal, tmp_path, monkeypatch): + first = anon_auth.ensure_portal_identity(blocking=True) + other_home = tmp_path / "profiles" / "two" + other_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(other_home)) + before = len(portal.calls) + second = anon_auth.ensure_portal_identity(blocking=True) + assert second["anon_token"] == first["anon_token"] + assert len(portal.calls) == before, "adoption must not touch the network" + assert portal.minted == 1 + + def test_gate_closed_persists_nothing_and_raises_gate_code(self, portal): + portal.gate_closed = True + with pytest.raises(anon_auth.AuthError) as exc: + anon_auth.ensure_portal_identity(blocking=True) + assert exc.value.code == "anon_gate_closed" + assert "nous" not in _load_auth_store().get("providers", {}) + # A process tries once: later bootstrap sites must not hit the portal again. + assert anon_auth.ensure_portal_identity(blocking=True) is None + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 + + def test_opt_out_bool_disables_everything(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False) + assert anon_auth.ensure_portal_identity(blocking=True) is None + assert portal.calls == [] + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + + def test_force_guest_overrides_opt_out_and_new_bypasses_shared_store(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False) + monkeypatch.setenv("HERMES_FORCE_GUEST", "1") + first = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(first) + monkeypatch.setenv("HERMES_FORCE_GUEST", "new") + second = anon_auth.ensure_portal_identity(blocking=True) + assert second["anon_token"] != first["anon_token"] + assert portal.minted == 2 + + +class TestResolverIsUnchanged: + def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + assert resolve_provider("auto") == "nous" + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + assert resolve_provider("auto") == "openrouter" + + def test_runtime_routes_to_welcome_host(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["provider"] == "nous" + assert runtime["base_url"].rstrip("/") == WELCOME + assert runtime["api_key"] + + +class TestTokenAcquisitionSeam: + def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["access_token"] = _jwt(exp=int(time.time()) - 10) + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + _save_auth_store(store) + portal.calls.clear() + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials() + paths = [p for _, p in portal.calls] + assert paths == ["/api/anonymous/token"] + assert "/api/oauth/token" not in paths + assert creds["base_url"].rstrip("/") == WELCOME + assert "quarantine" not in json.dumps(_load_auth_store()) + + def test_dead_credential_is_replaced_by_a_fresh_identity(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + portal.dead_tokens.add(first["anon_token"]) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials(force_refresh=True) + assert creds["api_key"] + state = _load_auth_store()["providers"]["nous"] + assert state["anon_token"] != first["anon_token"] + assert portal.minted == 2 + + def test_tool_gateway_token_path_reexchanges(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store, resolve_nous_access_token + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + _save_auth_store(store) + portal.calls.clear() + token = resolve_nous_access_token() + assert token + assert [p for _, p in portal.calls] == ["/api/anonymous/token"] + + +class TestModelPin: + """The pin is a property of the selected ROUTE (welcome host), never of profile state: a paid + pool credential routed to the portal host keeps its model even beside a guest singleton.""" + + def test_pin_keys_on_the_welcome_host_not_on_guest_state(self, portal): + anon_auth.ensure_portal_identity(blocking=True) # guest singleton exists + assert anon_auth.route_is_welcome_host(WELCOME) + assert not anon_auth.route_is_welcome_host("https://inference-api.nousresearch.com/v1") + assert not anon_auth.route_is_welcome_host("") + + def test_agent_init_pins_only_on_welcome_route(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from run_agent import AIAgent + welcome = AIAgent(provider="nous", base_url=WELCOME, api_key="k", model="openai/gpt-5", + quiet_mode=True, skip_context_files=True, skip_memory=True) + paid = AIAgent(provider="nous", base_url="https://inference-api.nousresearch.com/v1", api_key="k", + model="nous/paid-model", quiet_mode=True, skip_context_files=True, skip_memory=True) + assert welcome.model == anon_auth.GUEST_MODEL + assert paid.model == "nous/paid-model" + + +class TestLogout: + def test_logout_with_only_free_tier_is_a_true_noop(self, portal, capsys): + from types import SimpleNamespace + from hermes_cli.auth import _auth_file_path, logout_command + anon_auth.ensure_portal_identity(blocking=True) + before = _auth_file_path().read_bytes() + logout_command(SimpleNamespace(provider=None)) + out = capsys.readouterr().out.lower() + assert "not signed in" in out + assert "guest" not in out and "anonymous" not in out + assert _auth_file_path().read_bytes() == before + + def test_logout_of_real_account_clears_shared_store(self, portal, tmp_path): + from types import SimpleNamespace + from hermes_cli.auth import logout_command + from hermes_cli.auth_nous import persist_nous_credentials + persist_nous_credentials({"access_token": _jwt(client_id="hermes-cli", account_tier="free"), + "refresh_token": "rt-1", "expires_at": "2030-01-01T00:00:00+00:00", + "auth_method": "oauth_device_code"}) + assert _shared_store(tmp_path).get("refresh_token") == "rt-1" + logout_command(SimpleNamespace(provider="nous")) + assert _shared_store(tmp_path) == {} + assert "nous" not in _load_auth_store().get("providers", {}) + + +class TestModelSwitchCopy: + def test_switching_away_from_welcome_names_the_account_path_not_another_provider(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli import model_switch + monkeypatch.setattr(model_switch, "list_provider_models", lambda *a, **k: [], raising=False) + result = model_switch.switch_model("gpt-5", "nous", anon_auth.GUEST_MODEL, WELCOME) + assert not result.success + msg = (result.error_message or "").lower() + assert "hermes auth upgrade" in msg + assert "openrouter" not in msg and "switching" not in msg + + +class TestRotationNeverRewritesTheConversationModel: + """A credential rotation adopts an entry only if its route can serve the conversation's model. + The model is never changed by a swap; an ineligible entry is refused (swap returns False).""" + + def _agent(self, api_mode="chat_completions", model="nous/paid-model"): + from types import SimpleNamespace + return SimpleNamespace(provider="nous", api_mode=api_mode, base_url="https://inference-api.nousresearch.com/v1", + api_key="k", model=model, _client_kwargs={}, _credential_pool_entry_id="p", + _reapply_route_client_config=lambda **kw: None, _replace_primary_openai_client=lambda **kw: None, + _anthropic_client=SimpleNamespace(close=lambda: None), + _build_direct_anthropic_client=lambda key, url: object(), _anthropic_oauth_flag=lambda key: False) + + def test_paid_conversation_refuses_a_welcome_route_on_every_wire_mode(self, portal): + from types import SimpleNamespace + from agent.client_lifecycle import ClientLifecycleMixin + for mode, model in (("chat_completions", "nous/paid-model"), ("anthropic_messages", "anthropic/claude-sonnet")): + agent = self._agent(mode, model) + ok = ClientLifecycleMixin._swap_credential(agent, SimpleNamespace(id="g", runtime_api_key="jwt", runtime_base_url=WELCOME)) + assert ok is False + assert agent.model == model and agent.api_key == "k" and agent._credential_pool_entry_id == "p" + + def test_welcome_conversation_may_move_to_the_portal_host(self, portal): + from types import SimpleNamespace + from agent.client_lifecycle import ClientLifecycleMixin + agent = self._agent(model=anon_auth.GUEST_MODEL); agent.base_url = WELCOME + ok = ClientLifecycleMixin._swap_credential(agent, SimpleNamespace(id="p2", runtime_api_key="key", runtime_base_url="https://inference-api.nousresearch.com/v1")) + assert ok is True and agent.model == anon_auth.GUEST_MODEL + + +class TestBackgroundRetry: + def test_background_failure_releases_the_latch(self, portal): + import time as _t + portal.gate_closed = True + assert anon_auth.ensure_portal_identity(blocking=False) is None + for _ in range(50): + if not anon_auth._background_started: + break + _t.sleep(0.05) + assert anon_auth._background_started is False, "a failed background attempt must not consume the latch" + portal.gate_closed = False + anon_auth.ensure_portal_identity(blocking=False) + for _ in range(50): + if anon_auth.has_guest(): + break + _t.sleep(0.05) + assert anon_auth.has_guest() + + +class TestBackgroundLatchOnThreadFailure: + def test_thread_start_failure_releases_latch(self, portal, monkeypatch): + import threading + class Boom(threading.Thread): + def start(self): raise RuntimeError("can't start new thread") + monkeypatch.setattr(anon_auth.threading, "Thread", Boom) + assert anon_auth.ensure_portal_identity(blocking=False) is None + assert anon_auth._background_started is False + + +class TestIdentityOfRecordIsTheSharedStore: + def test_stale_profile_guest_adopts_a_newer_shared_account(self, portal, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + # A sibling profile signed in: the shared store now holds a real account. + from hermes_cli.auth_nous import _write_shared_nous_state + _write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli", account_tier="free"), + "refresh_token": "rt-sibling", "expires_at": "2030-01-01T00:00:00+00:00", + "auth_method": "oauth_device_code"}) + state = anon_auth.ensure_portal_identity(blocking=True) + assert not anon_auth.is_guest_state(state) + assert state["refresh_token"] == "rt-sibling" + assert _load_auth_store()["providers"]["nous"]["refresh_token"] == "rt-sibling" + assert _shared_store(tmp_path)["refresh_token"] == "rt-sibling", "the profile must never overwrite the shared account" + + def test_mint_persists_before_any_exchange_and_first_use_exchanges_once(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(first) and "access_token" not in first + assert [p for _, p in portal.calls] == ["/api/anonymous/create"], "mint alone; exchange is lazy" + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials() + assert creds["api_key"] + assert portal.minted == 1, "a stored credential is exchanged, never re-minted" + assert [p for _, p in portal.calls].count("/api/anonymous/token") == 1 + + def test_clearing_a_dead_guest_leaves_a_sibling_identity_alone(self, portal, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth_nous import _write_shared_nous_state + _write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli"), "refresh_token": "rt-sibling", + "expires_at": "2030-01-01T00:00:00+00:00", "auth_method": "oauth_device_code"}) + anon_auth.clear_dead_guest("test") + assert "nous" not in _load_auth_store().get("providers", {}) + assert _shared_store(tmp_path)["refresh_token"] == "rt-sibling" + + def test_lock_order_is_profile_then_shared(self, portal, monkeypatch): + order = [] + from hermes_cli import auth as auth_mod, auth_nous + real_profile, real_shared = auth_mod._auth_store_lock, auth_nous._nous_shared_store_lock + from contextlib import contextmanager + + @contextmanager + def profile(*a, **k): + order.append("profile") + with real_profile(*a, **k): + yield + + @contextmanager + def shared(*a, **k): + order.append("shared") + with real_shared(*a, **k): + yield + monkeypatch.setattr(auth_mod, "_auth_store_lock", profile) + monkeypatch.setattr(auth_nous, "_nous_shared_store_lock", shared) + anon_auth.ensure_portal_identity(blocking=True) + assert order[:2] == ["profile", "shared"] + + +class TestConnectorTokenPath: + def test_opt_out_hides_the_free_tier_from_connectors_including_cached_tokens(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + resolve_nous_runtime_credentials() # now a cached, valid JWT exists + from tools import managed_tool_gateway as mtg + assert mtg.read_nous_access_token() + _write_config(monkeypatch, guest=False) + assert mtg.peek_nous_access_token() is None + assert mtg.read_nous_access_token() is None + + def test_connector_path_replaces_a_dead_credential_once(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + store["providers"]["nous"]["access_token"] = _jwt(exp=1) + _save_auth_store(store) + portal.dead_tokens.add(first["anon_token"]) + from tools import managed_tool_gateway as mtg + token = mtg.read_nous_access_token() + assert token and token != _jwt(exp=1) + assert _load_auth_store()["providers"]["nous"]["anon_token"] != first["anon_token"] + assert portal.minted == 2 diff --git a/tests/hermes_cli/test_anon_first_notice.py b/tests/hermes_cli/test_anon_first_notice.py new file mode 100644 index 0000000000..b147952fa7 --- /dev/null +++ b/tests/hermes_cli/test_anon_first_notice.py @@ -0,0 +1,92 @@ +"""CLI one-time notice: an explicit-provider install learns once that the Nous free tier exists.""" + +import base64 +import json +import time + +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store +from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _seed_guest() -> None: + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = { + "auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001", + "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1"} + _save_auth_store(store) + + +class _RecordingConsole: + def __init__(self): + self.lines: list[str] = [] + + def print(self, *args, **kwargs): + self.lines.append(" ".join(str(a) for a in args)) + + +class _NoticeCLI(CLIAgentSetupMixin): + """Only what the notice path touches: the console seam.""" + + def __init__(self): + self.console = _RecordingConsole() + self._app = None + + def _console_print(self, *args, **kwargs): + self.console.print(*args, **kwargs) + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + + +def test_notice_prints_once_after_identity_appears_and_persists_the_flag(): + cli = _NoticeCLI() + + # Background mint has not landed yet: nothing to say. + cli._maybe_print_free_tier_available_notice() + assert cli.console.lines == [] + + _seed_guest() + cli._maybe_print_free_tier_available_notice() + assert len(cli.console.lines) == 1 + text = cli.console.lines[0] + assert anon_auth.FREE_TIER_AVAILABLE_NOTICE in text + assert "guest" not in text.lower() and "anonymous" not in text.lower() + + # Same process, and a fresh process reading the store: never again. + cli._maybe_print_free_tier_available_notice() + _NoticeCLI()._maybe_print_free_tier_available_notice() + assert len(cli.console.lines) == 1 + assert _load_auth_store()["providers"]["nous"][anon_auth.GUEST_NOTICE_FLAG] is True + assert not anon_auth.guest_notice_pending() + + +def test_signed_in_account_is_never_told_about_the_free_tier(): + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = { + "auth_method": "oauth", "access_token": _jwt(client_id="hermes-cli", account_tier="pro"), + "refresh_token": "rt", "expires_at": "2999-01-01T00:00:00+00:00"} + _save_auth_store(store) + cli = _NoticeCLI() + + cli._maybe_print_free_tier_available_notice() + + assert cli.console.lines == [] + assert anon_auth.mark_guest_notice_shown() is False + assert anon_auth.GUEST_NOTICE_FLAG not in _load_auth_store()["providers"]["nous"] diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py new file mode 100644 index 0000000000..c902be5510 --- /dev/null +++ b/tests/hermes_cli/test_anon_picker.py @@ -0,0 +1,91 @@ +"""Model pickers under a Nous free-tier identity. + +A guest identity carrying inference shows one row, "Nous · free tier", with the single model +``nous/welcome``; the same guest with ``nous.guest: false`` shows no Nous row at all. The rule lives +in one helper (``_free_tier_nous_row``) and this file exercises it through the real row builders +against a temp ``HERMES_HOME`` with the network catalog fetch stubbed. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.auth import _save_auth_store + +GUEST_STATE = { + "auth_method": "anonymous", + "account_tier": "anonymous", + "anon_token": "anon_t", + "access_token": "aaa.bbb.ccc", + "expires_at": "2030-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1", +} + + +@pytest.fixture +def guest_home(monkeypatch, tmp_path): + """Seed a guest identity as the only Nous state and keep every row builder offline.""" + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY", "LM_API_KEY", "LM_BASE_URL"): + monkeypatch.delenv(var, raising=False) + _save_auth_store({"active_provider": "nous", "providers": {"nous": dict(GUEST_STATE)}}) + + # No network from any lap: models.dev, the Portal catalog, and Ollama Cloud all stubbed. + from agent import models_dev + from hermes_cli import models as models_mod + from hermes_cli import model_switch_providers as msp + monkeypatch.setattr(models_dev, "fetch_models_dev", lambda *a, **k: {}) + monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda *a, **k: ["anthropic/claude-x", "openai/gpt-y"]) + monkeypatch.setattr(models_mod, "fetch_ollama_cloud_models", lambda *a, **k: []) + monkeypatch.setattr(msp, "_nous_picker_model_ids", lambda *a, **k: pytest.fail("guest must not fetch the Portal catalog")) + return Path(os.environ["HERMES_HOME"]) + + +def _write_config(monkeypatch, home: Path, text: str) -> None: + (home / "config.yaml").write_text(text) + from hermes_cli import config as cfg_mod + for attr in ("_config_cache", "_cached_config"): + if hasattr(cfg_mod, attr): + monkeypatch.setattr(cfg_mod, attr, None, raising=False) + + +def _nous_rows(rows): + return [r for r in rows if str(r.get("slug", "")).lower() == "nous"] + + +def _cli_nous_rows(config): + from hermes_cli.main_provider_setup import _build_provider_picker_rows + ordered, _ = _build_provider_picker_rows(config, "nous", {}, {}) + return [(key, label) for key, label, _members in ordered if key == "nous"] + + +def test_guest_identity_shows_free_tier_row_with_only_welcome_model(guest_home, monkeypatch): + from hermes_cli.model_switch_providers import list_picker_providers + rows = _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) + assert len(rows) == 1 + row = rows[0] + assert "free tier" in row["name"] + assert row["models"] == ["nous/welcome"] + assert row["total_models"] == 1 + rendered = repr(row).lower() + assert "guest" not in rendered and "anonymous" not in rendered + + # The `hermes model` provider picker applies the same rule from the same helper. + cli_rows = _cli_nous_rows({}) + assert len(cli_rows) == 1 + assert "free tier" in cli_rows[0][1] + assert "guest" not in cli_rows[0][1].lower() and "anonymous" not in cli_rows[0][1].lower() + + +def test_guest_identity_with_guest_off_hides_the_nous_row(guest_home, monkeypatch): + _write_config(monkeypatch, guest_home, "nous:\n guest: false\n") + from hermes_cli import anon_auth + assert anon_auth.has_guest() and not anon_auth.guest_enabled() + + from hermes_cli.model_switch_providers import list_picker_providers + assert _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) == [] + assert _cli_nous_rows({"nous": {"guest": False}}) == [] diff --git a/tests/hermes_cli/test_anon_surfaces.py b/tests/hermes_cli/test_anon_surfaces.py new file mode 100644 index 0000000000..9bd5ea10d1 --- /dev/null +++ b/tests/hermes_cli/test_anon_surfaces.py @@ -0,0 +1,144 @@ +"""Nous free tier on the read-only display surfaces and the keepalive. + +Contract (R-USR-1): wherever a free-tier identity renders (``hermes auth status nous``, +``hermes auth list``, ``hermes status``, ``hermes portal info``) the user sees the free-tier label +plus the upgrade hint, and never the internal identity vocabulary. A real account keeps its normal +rendering. The keepalive has nothing to keep alive for the free tier and must not start a thread. +""" + +from __future__ import annotations + +import base64 +import json +import re +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from hermes_cli import anon_auth, auth_commands, nous_account, nous_auth_keepalive, portal_cli, status_auth +from hermes_cli.auth import _load_auth_store # noqa: F401 (store import name kept for parity with core tests) +from hermes_constants import get_hermes_home + +WELCOME = "https://welcome-api.nousresearch.com/v1" +# Words that must never appear on a user-facing free-tier surface. +_FORBIDDEN = re.compile(r"guest|anonymous|user id|org id|nas_user|nas_organisation", re.IGNORECASE) + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:abc", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 10 ** 8, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _write_auth(nous_state: dict) -> None: + home = Path(get_hermes_home()) + home.mkdir(parents=True, exist_ok=True) + (home / "auth.json").write_text(json.dumps({"active_provider": "nous", "providers": {"nous": nous_state}})) + + +def _guest_state() -> dict: + return { + "auth_method": "anonymous", "account_tier": "anonymous", "anon_token": "anon_t", + "access_token": _jwt(), "expires_at": "2030-01-01T00:00:00+00:00", + "inference_base_url": WELCOME, "user_id": "nas_user:abc", "org_id": "nas_organisation:def", + } + + +def _account_state() -> dict: + return { + "auth_method": "oauth_device_code", "client_id": "hermes-cli", + "access_token": _jwt(sub="nas_user:real", client_id="hermes-cli", account_tier="standard", paid_access=True), + "refresh_token": "rt_live", "expires_at": "2030-01-01T00:00:00+00:00", + "portal_base_url": "https://portal.nousresearch.com", "inference_base_url": "https://inference-api.nousresearch.com/v1", + } + + +@pytest.fixture +def isolated_store(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + # No network: the account lookup is derived from the JWT the store already holds. + monkeypatch.setattr(nous_account, "_fetch_nous_account_info", + lambda *a, **k: pytest.fail("portal fetch must not happen on a read-only surface")) + nous_account.reset_nous_portal_account_info_cache() + import hermes_cli.auth as auth_mod + auth_mod.invalidate_nous_auth_status_cache() + yield + nous_account.reset_nous_portal_account_info_cache() + auth_mod.invalidate_nous_auth_status_cache() + + +def _render_all(capsys) -> dict[str, str]: + out: dict[str, str] = {} + auth_commands.auth_status_command(SimpleNamespace(provider="nous")) + out["auth status"] = capsys.readouterr().out + auth_commands.auth_list_command(SimpleNamespace(provider="nous")) + out["auth list"] = capsys.readouterr().out + ctx = SimpleNamespace(config={}, nous_logged_in=False, nous_inference_present=False, nous_account_info=None) + status_auth._render_auth_providers(ctx) + out["hermes status"] = capsys.readouterr().out + portal_cli._cmd_status(SimpleNamespace()) + out["portal info"] = capsys.readouterr().out + return out + + +def test_free_tier_renders_free_tier_copy_on_every_surface(isolated_store, capsys): + _write_auth(_guest_state()) + rendered = _render_all(capsys) + for surface, text in rendered.items(): + assert "free tier" in text.lower(), f"{surface} did not name the free tier:\n{text}" + assert anon_auth.FREE_TIER_LABEL in text and anon_auth.GUEST_MODEL in text, surface + assert anon_auth.UPGRADE_HINT in text, f"{surface} lacks the upgrade hint:\n{text}" + leaked = _FORBIDDEN.search(text) + assert leaked is None, f"{surface} leaked {leaked.group(0)!r}:\n{text}" + # Billing / entitlement copy for the free tier points at the upgrade path, never at billing. + info = nous_account.get_nous_portal_account_info() + assert info.is_anonymous_tier + message = nous_account.format_nous_portal_entitlement_message(info, capability="managed web tools") + assert message == nous_account.FREE_TIER_NEEDS_ACCOUNT + assert "billing" not in message.lower() and _FORBIDDEN.search(message) is None + + +def test_real_account_keeps_account_rendering(isolated_store, capsys): + _write_auth(_account_state()) + rendered = _render_all(capsys) + for surface, text in rendered.items(): + assert "free tier" not in text.lower(), f"{surface} mislabelled a real account:\n{text}" + assert anon_auth.UPGRADE_HINT not in text, surface + assert "logged in" in rendered["auth status"] + assert "credentials" in rendered["auth list"] + info = nous_account.get_nous_portal_account_info() + assert not info.is_anonymous_tier + assert nous_account.format_nous_portal_entitlement_message(info) is None # paid_access claim entitles + + +def test_keepalive_does_not_start_for_free_tier(isolated_store, monkeypatch): + started: list = [] + + class _Thread: + def __init__(self, *args, **kwargs): + self._name = kwargs.get("name") + def start(self): + started.append(self._name) + def is_alive(self): + return True + def join(self, timeout=None): + pass + monkeypatch.setattr(nous_auth_keepalive.threading, "Thread", _Thread) + monkeypatch.setattr(nous_auth_keepalive, "_keepalive_thread", None) + + _write_auth(_guest_state()) + assert nous_auth_keepalive.start_nous_auth_keepalive(interval_seconds=900) is None + assert started == [] + + _write_auth(_account_state()) + thread = nous_auth_keepalive.start_nous_auth_keepalive(interval_seconds=900) + assert thread is not None and started == ["nous-auth-keepalive"] + monkeypatch.setattr(nous_auth_keepalive, "_keepalive_thread", None) diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py new file mode 100644 index 0000000000..cf317ace0d --- /dev/null +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -0,0 +1,178 @@ +"""``hermes auth upgrade``: the free tier signs into a Nous account, keeping its connectors. + +Driven through a fake portal covering the device-code endpoints plus the promotion intent/status +surface, so the wire contract (both codes in the intent, status-driven outcome, token grant +persisted over the guest singleton) is exercised rather than mocked away. +""" + +from __future__ import annotations + +import base64 +import json +import time +from types import SimpleNamespace +from urllib.parse import parse_qs + +import httpx +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_file_path, _load_auth_store + +WELCOME = "https://welcome-api.nousresearch.com/v1" +INFERENCE = "https://inference-api.nousresearch.com/v1" +PORTAL = "https://portal.example.test" +REFRESH_TOKEN = "rt-upgraded-1" +EMAIL = "sid@example.test" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +class FakePortal: + """NAS anonymous surface + device-code endpoints. Records every call; scenarios flip behaviour.""" + + def __init__(self): + self.calls: list[tuple[str, str]] = [] + self.minted = 0 + self.intent_bodies: list[dict] = [] + self.status_bodies: list[dict] = [] + self.device_code = "dev-code-123" + self.user_code = "ABCD-EFGH" + # Sequence of promotion-status payloads; the last one repeats. + self.status_sequence: list[dict] = [{"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL}] + self.token_grants = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + self.calls.append((request.method, path)) + if path.startswith("/api/anonymous/") and not request.headers.get("x-anonymous-api-secret"): + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if path == "/api/anonymous/create": + self.minted += 1 + return httpx.Response(201, json={"user_id": f"nas_user:{self.minted}", "org_id": "nas_org:1", + "token": f"anon_{self.minted:04d}", "idle_ttl_days": 14}) + if path == "/api/anonymous/token": + return httpx.Response(200, json={"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1", + "inference_base_url": WELCOME}) + if path == "/api/oauth/device/code": + return httpx.Response(200, json={ + "device_code": self.device_code, "user_code": self.user_code, + "verification_uri": f"{PORTAL}/device", + "verification_uri_complete": f"{PORTAL}/device?user_code={self.user_code}", + "expires_in": 600, "interval": 5}) + if path == "/api/anonymous/promotion-intent": + body = json.loads(request.content) + self.intent_bodies.append(body) + if not (body.get("user_code") and body.get("device_code")): + return httpx.Response(400, json={"error": "invalid_request"}) + return httpx.Response(200, json={"claim_code": "clm_1", "claim_url": f"{PORTAL}/device", + "expires_in": 600, "interval": 0}) + if path == "/api/anonymous/promotion-status": + body = json.loads(request.content) + self.status_bodies.append(body) + idx = min(len(self.status_bodies) - 1, len(self.status_sequence) - 1) + return httpx.Response(200, json=self.status_sequence[idx]) + if path == "/api/oauth/token": + form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + if form.get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code": + return httpx.Response(400, json={"error": "unsupported_grant_type"}) + if form.get("device_code") != self.device_code: + return httpx.Response(400, json={"error": "invalid_grant"}) + self.token_grants += 1 + return httpx.Response(200, json={ + "access_token": _jwt(sub="nas_user:9", client_id="hermes-cli", account_tier="free"), + "refresh_token": REFRESH_TOKEN, "token_type": "Bearer", "expires_in": 900, + "scope": "inference:invoke tool:invoke", "inference_base_url": INFERENCE}) + return httpx.Response(500, json={"error": f"unexpected {path}"}) + + +@pytest.fixture +def portal(monkeypatch, tmp_path): + fake = FakePortal() + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL) + monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret") + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + from hermes_cli import auth_nous + + def _client(timeout_seconds, verify): + return httpx.Client(transport=httpx.MockTransport(fake.handler), base_url=PORTAL) + monkeypatch.setattr(auth_nous, "_nous_http_client", _client) + real_client = httpx.Client + + class _RoutedClient(real_client): + def __init__(self, *a, **kw): + kw.pop("verify", None) + kw["transport"] = httpx.MockTransport(fake.handler) + super().__init__(*a, **kw) + monkeypatch.setattr(httpx, "Client", _RoutedClient) + anon_auth._background_started = False + anon_auth._mint_failed = False + anon_auth._forced_new_done = False + return fake + + +def _shared_store(tmp_path) -> dict: + p = tmp_path / "shared-store" / "nous_auth.json" + return json.loads(p.read_text()) if p.exists() else {} + + +def _args(): + return SimpleNamespace(no_browser=True, timeout=None) + + +class TestUpgrade: + def test_intent_carries_both_device_codes_from_the_code_request(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + anon_auth.upgrade_guest(_args()) + assert len(portal.intent_bodies) == 1 + body = portal.intent_bodies[0] + assert body["user_code"] == portal.user_code + assert body["device_code"] == portal.device_code + assert body["token"].startswith("anon_") + paths = [p for _, p in portal.calls] + assert paths.index("/api/oauth/device/code") < paths.index("/api/anonymous/promotion-intent") + assert paths.index("/api/anonymous/promotion-intent") < paths.index("/api/oauth/token") + + def test_declined_in_browser_prints_copy_and_leaves_auth_store_untouched(self, portal, capsys, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + before = _auth_file_path().read_bytes() + shared_before = _shared_store(tmp_path) + portal.status_sequence = [{"status": "pending"}, {"status": "voided", "reason": "user_declined"}] + code = anon_auth.upgrade_guest(_args()) + out = capsys.readouterr().out + assert code == 1 + assert "Sign-in was rejected in the browser." in out + assert portal.token_grants == 0 + assert _auth_file_path().read_bytes() == before + assert _shared_store(tmp_path) == shared_before + + def test_completed_promotion_signs_in_and_keeps_no_free_tier_fields(self, portal, capsys, tmp_path): + guest = anon_auth.ensure_portal_identity(blocking=True) + assert _shared_store(tmp_path).get("anon_token") == guest["anon_token"] + code = anon_auth.upgrade_guest(_args()) + out = capsys.readouterr().out + assert code == 0 + assert f"Signed in as {EMAIL}. Your connectors are kept." in out + lowered = out.lower() + for banned in ("guest", "anonymous", "claim"): + assert banned not in lowered, f"{banned!r} leaked into user-facing output:\n{out}" + store = _load_auth_store() + state = store["providers"]["nous"] + assert store["active_provider"] == "nous" + assert "anon_token" not in state + assert state.get("auth_method") != anon_auth.ANON_AUTH_METHOD + assert not anon_auth.is_guest_state(state) + assert state["refresh_token"] == REFRESH_TOKEN + shared = _shared_store(tmp_path) + assert shared.get("refresh_token") == REFRESH_TOKEN + assert "anon_token" not in shared diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index a7504d1d4c..458c4daf31 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -38,13 +38,22 @@ def auth_json_path(): def _read_nous_provider_state() -> Optional[dict]: + """The profile's Nous state, or None. A free-tier identity counts only while the free tier is on: + with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever + attached to a request.""" try: path = auth_json_path() if not path.is_file(): return None providers = json.loads(path.read_text(encoding="utf-8-sig")).get("providers", {}) nous_provider = providers.get("nous", {}) if isinstance(providers, dict) else None - return nous_provider if isinstance(nous_provider, dict) else None + if not isinstance(nous_provider, dict): + return None + from hermes_cli.anon_auth import guest_enabled, is_guest_state + + if is_guest_state(nous_provider) and not guest_enabled(): + return None + return nous_provider except Exception: return None @@ -87,10 +96,25 @@ def peek_nous_access_token() -> Optional[str]: def read_nous_access_token() -> Optional[str]: - """Read a Nous Subscriber OAuth access token from auth store or env override.""" + """Read a Nous Subscriber OAuth access token from auth store or env override. + + With no Nous identity at all, the free tier is set up here (blocking, short timeout): this is + the guarantee that managed-tool and connector calls always have a bearer once the free tier is + on, whichever surface booted the process. + """ if explicit := _read_user_token_override(): return explicit nous_provider = _read_nous_provider_state() or {} + if not nous_provider: + try: + from hermes_cli.anon_auth import ensure_portal_identity + + nous_provider = ensure_portal_identity(blocking=True) or {} + except Exception as exc: + logger.debug("Nous free tier setup from tool gateway skipped: %s", exc) + nous_provider = {} + if not nous_provider: + return None cached_token = peek_nous_access_token() if cached_token and not _access_token_is_expiring(nous_provider.get("expires_at"), _NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS): return cached_token @@ -100,10 +124,30 @@ def read_nous_access_token() -> Optional[str]: if refreshed_token := _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)): return refreshed_token except Exception as exc: + # Same dead-credential rule as inference (one place decides it: anon_auth): a retired free-tier + # identity is replaced once, here, instead of handing back its stale token forever. + from hermes_cli.anon_auth import AnonCredentialDead + + if isinstance(exc, AnonCredentialDead): + return _replace_dead_guest_token(nous_provider) logger.debug("Nous access token refresh failed: %s", exc) return cached_token +def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: + from hermes_cli.anon_auth import clear_dead_guest, ensure_portal_identity + from hermes_cli.auth import resolve_nous_access_token + + clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) + try: + if ensure_portal_identity(blocking=True) is None: + return None + return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) + except Exception as exc: + logger.debug("Nous free tier replacement after a retired credential failed: %s", exc) + return None + + def get_tool_gateway_scheme() -> str: """Return configured shared gateway URL scheme.""" scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower() or _DEFAULT_TOOL_GATEWAY_SCHEME diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md new file mode 100644 index 0000000000..12c01d5ab8 --- /dev/null +++ b/website/docs/user-guide/free-tier.md @@ -0,0 +1,140 @@ +--- +sidebar_position: 3 +title: "Free tier and signing in" +description: "What Hermes gives you before you add a key or sign in, how the free tier coexists with your own API key, how to sign in, and how to turn it off." +--- + +# Free tier and signing in + +A fresh Hermes install works before you paste an API key or sign in anywhere. The first time you +run a command, Hermes sets up the **Nous free tier** (a few seconds, shown as "Setting up free +inference…") and answers on the `nous/welcome` model. Nothing to configure, no wizard to click +through. `hermes setup` is still there when you want it; it is never forced. + +## What you get out of the box + +| | Free tier | After signing in | +|---|---|---| +| Inference | `nous/welcome` (one model) | Full Nous Portal catalog | +| Connectors (Gmail, Linear, Notion, ...) | Yes | Yes | +| Paid tools through the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, cloud browser) | No | Yes, billed to your subscription | +| Credits or a balance | None | Yes | + +"Connectors" are the third-party accounts you link on the Nous portal so the agent can act in +them. They work on the free tier without any sign-in. + +While the free tier carries inference, the banner and `hermes auth status` read +`Nous · free tier · nous/welcome`, and `hermes model` lists a **Nous · free tier** row with that +single model. Asking for another model on the free tier prints a pointer instead of switching +silently: + +```text +gpt-5 needs a Nous account or an API key. Run `hermes auth upgrade` or `hermes model`. +``` + +Calling a paid tool prints `This tool requires a Nous account. Run hermes auth upgrade.` and the +turn continues without it. + +If `model.default` in `config.yaml` names something other than `nous/welcome` while the free tier +is doing inference, Hermes uses `nous/welcome` anyway and says so in one line. The free tier +serves exactly one model. + +## Using your own API key alongside it + +The free tier is the last resort, never a preference. Any provider you configure wins: + +| You have | Inference runs on | Connectors | +|---|---|---| +| Nothing | Nous free tier (`nous/welcome`) | Free tier | +| An API key in `.env` (OpenRouter, OpenAI, Anthropic, ...) | Your key | Free tier | +| `model.provider` set in `config.yaml` | That provider | Free tier | +| A Nous Portal sign-in | Nous Portal | Your account | + +On an install that already has a provider, the free tier sets itself up quietly in the +background on the next start so connectors have something to authenticate with, and prints a +one-time notice: + +```text +Free Nous inference and connectors are now available. `hermes model` to try them, `hermes auth upgrade` to sign in. +``` + +You can pick the free tier explicitly from `hermes model` (or `/model`) like any other provider. + +## Signing in + +```bash +hermes auth upgrade +``` + +The command name is provisional and may change in a later release; the behaviour will not. + +1. Hermes prints a URL and a short code, and opens the browser unless you pass `--no-browser` + or you are in an SSH session. Never share the code. +2. Sign in to Nous Portal in the browser and confirm. +3. Back in the terminal: `Signed in as you@example.com. Your connectors are kept.` + +Connectors you linked on the free tier carry over. Inference moves to the Nous Portal catalog, +paid tools unlock, and `hermes auth status` shows your account instead of the free-tier line. + +`hermes auth upgrade` is offered wherever the free tier is present, including installs that +run inference on their own API key. Signing in still unlocks paid tools for those installs. + +:::note Plain login starts fresh +`hermes auth add nous --type oauth` also signs you in, but it replaces the free tier outright and +does not carry your connectors over. Use `hermes auth upgrade` when you have connectors you want +to keep. +::: + +## Turning the free tier off + +```bash +hermes config set nous.guest false +``` + +`nous.guest` is a normal `config.yaml` setting (default `true`), not an environment variable. +With it off: + +| | `nous.guest: true` (default) | `nous.guest: false` | +|---|---|---| +| Free inference on `nous/welcome` | Available | Off | +| Connectors without sign-in | Available | Off | +| Free-tier row in `hermes model` | Shown | Hidden | +| Fresh install with nothing configured | Chats immediately | Offered `hermes setup` | +| Signing in with a Nous account | Works | Works | + +Nothing else changes. A signed-in Nous account, your own API keys, and every other provider work +exactly as before. Set it back to `true` and the free tier returns on the next command that +needs it. + +## What `hermes logout` does + +| Situation | Result | +|---|---| +| Only the free tier is present | Nothing is cleared. Hermes prints: `You're not signed in. Free inference and connectors are always on. Run hermes auth to sign in with a Nous account.` | +| Signed in with a Nous account | The sign-in is removed from this profile and from the shared store, so no other profile on this machine picks it back up. With `nous.guest: true` the install returns to the free tier the next time it needs inference or a connector. | +| Another provider is active | Unchanged behaviour: that provider's stored credential is cleared. | + +There is no command to reset or recreate the free tier. It is created once and looks after +itself. + +## Troubleshooting + +| Symptom | What it means | What to do | +|---|---|---| +| First command prints `It looks like Hermes isn't configured yet` and offers `hermes setup` | The free tier could not be set up within a few seconds: you are offline, or the free tier is not open on the portal Hermes is pointed at, or it is rate limited. | Come back online and run the command again, or run `hermes setup` and add a provider of your own. Nothing is left half-configured. | +| `Nous free tier is not open on this portal.` | The portal Hermes is pointed at is not offering the free tier right now. If you set `HERMES_PORTAL_BASE_URL`, that portal may not have it at all. | Sign in with an account, unset a portal override you no longer need, or add your own key with `hermes setup`. | +| `Nous free tier is rate limited; try again shortly.` | The portal is throttling new free-tier setups at the moment. | Wait a few minutes and retry, or add your own key with `hermes setup`. | +| `This tool requires a Nous account.` | You called a paid Tool Gateway tool on the free tier. | `hermes auth upgrade`, or configure that tool with your own key in `hermes tools`. | +| Model picker shows only `nous/welcome` under Nous | Expected on the free tier. | Sign in for the full catalog, or add an API key for another provider. | +| The free tier stopped working after two weeks away | The free-tier identity expired (see below) and is replaced on next use. | Nothing; run any command. Connectors linked before the gap need to be linked again unless you had signed in. | + +## Privacy + +To make the free tier work, Hermes creates an identity on the Nous portal the first time it +needs one and stores the credential in your Hermes directory, shared across the profiles under +that directory. That identity holds no email address, no name, and no other personal data; it +exists so inference and connector calls can be authenticated and rate limited. It expires after +14 days without use, at which point Hermes transparently creates a new one the next time you run +a command. Signing in with `hermes auth upgrade` moves what that identity holds (your linked +connectors) into your account. Turning the free tier off with `nous.guest: false` means no +identity is created or used at all. diff --git a/website/sidebars.ts b/website/sidebars.ts index 34f3693fe6..35b8659473 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -29,6 +29,7 @@ const sidebars: SidebarsConfig = { 'user-guide/windows-native', 'user-guide/windows-wsl-quickstart', 'user-guide/configuration', + 'user-guide/free-tier', 'user-guide/managed-scope', 'user-guide/configuring-models', {