diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a6af92bda361c..21fbc2150784a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2234,13 +2234,36 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: return None, None if runtime is None and nous: logger.debug("Auxiliary Nous: runtime JWT refresh failed; checking stored auth.json token.") + if runtime is not None: + api_key, base_url = runtime + else: + api_key = _nous_api_key(nous or {}) + if not api_key: + logger.warning( + "Auxiliary Nous client unavailable: no usable inference JWT found " + "(run: hermes auth add nous)." + ) + _mark_provider_unhealthy("nous", ttl=60) + return None, None + base_url = str( + (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) + ).rstrip("/") + lane = "vision" if vision else "text" + # The free tier's host serves exactly one model, for every lane: asking it for the Portal's + # recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead. + # Vision rides the same id (the backing model is multimodal; a backing that is not answers + # the request with the upstream's own error, which the ladder handles like any other). + from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host global auxiliary_is_nous + if route_is_welcome_host(base_url): + auxiliary_is_nous = True + logger.debug("Auxiliary/%s: Nous free tier; using %s", lane, GUEST_MODEL) + return _create_openai_client(api_key=api_key, base_url=base_url), GUEST_MODEL auxiliary_is_nous = True logger.debug("Auxiliary client: Nous Portal") # Portal recommended-models is authoritative (tier-aware); _NOUS_MODEL when unreachable/null. # Probes skip the lookup: exact model is irrelevant and it hits the network. model = _NOUS_MODEL - lane = "vision" if vision else "text" if not _aux_probe_active(): try: from hermes_cli.models import get_nous_recommended_aux_model @@ -2256,20 +2279,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: "falling back to %s", lane, exc, model, ) - if runtime is not None: - api_key, base_url = runtime - else: - api_key = _nous_api_key(nous or {}) - if not api_key: - logger.warning( - "Auxiliary Nous client unavailable: no usable inference JWT found " - "(run: hermes auth add nous)." - ) - _mark_provider_unhealthy("nous", ttl=60) - return None, None - base_url = str( - (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) - ).rstrip("/") return _create_openai_client(api_key=api_key, base_url=base_url), model @@ -2998,7 +3007,7 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool: _PAYMENT_KEYWORDS = ( "credits", "insufficient funds", "can only afford", "billing", "payment required", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", "requires a subscription", "upgrade for access", "upgrade for higher limits", "reached your session usage limit", "quota exceeded", "quota_exceeded", "too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted", @@ -3031,7 +3040,7 @@ def _nous_portal_account_has_fresh_paid_access() -> bool: _RATE_LIMIT_BILLING_KEYWORDS = ( "credits", "insufficient funds", "billing", "payment required", "can only afford", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", ) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 2eeb6c89e9f36..acddfbf722b8d 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2707,6 +2707,7 @@ def _chat_stream_created(self, raw_stream: Any) -> None: response = self._attempt_stream_response = getattr(raw_stream, "response", None) self.agent._capture_rate_limits(response) self.agent._capture_credits(response) + self.agent._capture_nous_model_switch(response) self.agent._stream_diag_capture_response(self.clients.diag, response) self.agent._check_openrouter_cache_status(response) self._writer_token = claim_stream_writer(self.agent) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index cd6005cbd8b55..91f3114b7d84c 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -118,6 +118,17 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: return AgentNotice(text=text, level=level, kind=CREDITS_NOTICE_KIND, key=key, id=key) +def _is_nous_welcome_route(base_url: str) -> bool: + """True when *base_url* is the Nous welcome host, which serves only the free tier. Local data only; + False wherever the free tier is not built in. The host is the evidence, not the model name: the paid + inference host can serve ``nous/welcome`` to a named account, and that account's depletion is real.""" + try: + from hermes_cli.anon_auth import route_is_welcome_host + except ImportError: + return False + return route_is_welcome_host(base_url) + + def is_free_tier_model(model: str, base_url: str = "") -> bool: """True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix @@ -130,6 +141,11 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: return True if not base_url: return False + # (4) the Nous free tier: the welcome host serves only the free tier. A free-tier identity carries $0 + # by design, so the portal seed reports paid_access=False for it; that is not a depleted account, and + # "run /topup" means nothing to it. Local data only, same as the rules above. + if _is_nous_welcome_route(base_url): + return True try: from hermes_cli.models import _is_model_free from hermes_cli.models_pricing import peek_cached_pricing diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 8c1d0030ea5ba..09488986ddc21 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -10,6 +10,7 @@ import enum import json import logging +import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, Iterator, Optional, Sequence @@ -514,9 +515,44 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: return verdict +def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: + """The Nous inference gateway's welcome-tier (free tier) refusals, read from the structured body. + + A 429 carrying a fairshare ``reason`` is either a tier gate (``model_not_free`` / + ``feature_not_free``: the model or feature is never served on the free tier, so retrying is + pointless — abort this route and fall back) or capacity (``at_capacity`` / ``admission_closed`` + / ``rate_limited``: honour ``retry_after``, never rotate the free tier's only credential). A + 400/403 whose message names the wrong host or a dark tier is deterministic for the request. + The parsed refusal rides ``error_context`` so the terminal copy can say what happened. + """ + from hermes_cli.anon_auth import ( + WELCOME_TIER_GATE_REASONS, parse_welcome_refusal, welcome_route_refusal) + status = c.status_code + if status == 429: + refusal = parse_welcome_refusal(c.body) + if refusal is None: + return None + ctx = {"welcome_refusal": refusal} + if refusal["reason"] in WELCOME_TIER_GATE_REASONS: + return _v(_R.model_not_found, retryable=False, should_fallback=True, error_context=ctx) + if refusal["retry_after"] > 0: + ctx["reset_at"] = time.time() + refusal["retry_after"] + return _v(_R.rate_limit, should_fallback=True, error_context=ctx) + kind = welcome_route_refusal(status, c.msg) + if kind is None: + return None + ctx = {"welcome_route": kind} + if status == 403: + return _v(_R.auth_permanent, retryable=False, should_fallback=True, error_context=ctx) + return _v(_R.format_error, retryable=False, should_fallback=True, error_context=ctx) + + def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: """Highest-priority provider-specific shapes that a status code would misroute.""" msg, status = c.msg, c.status_code + welcome = _nous_welcome_tier(c) + if welcome is not None: + return welcome # Safety refusal before status classification so a 400 block isn't downgraded # to format_error and a status-less block isn't left retryable (#18028). if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): diff --git a/agent/rate_limit_credits.py b/agent/rate_limit_credits.py index 9dc0f2d45e236..44c6f7a2003d0 100644 --- a/agent/rate_limit_credits.py +++ b/agent/rate_limit_credits.py @@ -49,6 +49,19 @@ def get_rate_limit_state(self): """Return the last captured RateLimitState, or None.""" return self._rate_limit_state + def _capture_nous_model_switch(self, http_response: Any) -> None: + """Record the Nous gateway's ``x-nous-model-switch`` header (a named account asked for the + free tier's model; the gateway served its backing model and named it). Applied between + calls by ``hermes_cli.anon_auth.apply_model_switch``. Fail-open.""" + headers = _response_headers(http_response) + if not headers: + return + try: + from hermes_cli.anon_auth import note_model_switch + note_model_switch(self, headers) + except Exception: + pass # Never let header parsing break the agent loop + def _capture_anthropic_response_headers(self, http_response: Any) -> None: """Capture rate-limit + credits state from Anthropic Messages response headers (the SDK's aggregated ``Message`` drops them). Fail-open.""" diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 194ee22f954e4..379a5e450936f 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -219,6 +219,13 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> NousRateGu ) if agent.provider == "nous": + # A gateway ``x-nous-model-switch`` recorded on the previous response moves this session + # (and the config default, when it still names the free tier's model) before the next call. + try: + from hermes_cli.anon_auth import apply_model_switch + apply_model_switch(agent) + except Exception: + pass try: from agent.nous_rate_guard import ( nous_rate_limit_remaining, format_remaining as _fmt_nous_remaining diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index 38019505fe03a..b71d7862c8f98 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -648,6 +648,19 @@ def _print_nonretryable_auth_guidance( _vlines(agent, " • Check credits: https://openrouter.ai/settings/credits") +def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str: + """Copy for a Nous free-tier refusal the classifier parsed (``welcome_refusal`` / + ``welcome_route`` in ``error_context``); empty for every other error.""" + ctx = getattr(classified, "error_context", None) or {} + refusal, route = ctx.get("welcome_refusal"), ctx.get("welcome_route") + if not refusal and not route: + return "" + from hermes_cli.anon_auth import welcome_refusal_copy, welcome_route_refusal_copy + if refusal: + return welcome_refusal_copy(refusal, model=str(model or ""), in_chat=in_chat) + return welcome_route_refusal_copy(str(route), in_chat=in_chat) + + # Terminal status label per non-retryable reason (default names the HTTP status). _NONRETRYABLE_LABELS = { FailoverReason.content_policy_blocked: "Provider safety filter blocked this request", @@ -683,7 +696,12 @@ def nonretryable_client_error_result( f" 🔌 Provider: {provider} Model: {model}", f" 🌐 Endpoint: {base_url}", ) - if classified.is_auth or classified.reason == FailoverReason.billing: + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + # A free-tier gate or a wrong-host refusal: the way forward is a sign-in or another + # provider, never the key/credits advice below. + _vlines(agent, f" 💡 {_welcome_hint}") + elif classified.is_auth or classified.reason == FailoverReason.billing: _print_nonretryable_auth_guidance( agent, classified, status_code=status_code, provider=provider, base_url=base_url, model=model ) @@ -738,7 +756,10 @@ def nonretryable_client_error_result( classified=classified, summary=_nonretryable_summary, messages=messages, api_call_count=api_call_count, provider=provider, base_url=base_url, model=model, ) - result = _failed_turn_result(_nonretryable_summary, messages, api_call_count, _nonretryable_summary) + _final_response = _nonretryable_summary + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" + result = _failed_turn_result(_final_response, messages, api_call_count, _nonretryable_summary) # Same verdict fields as the max-retries path: without them the UI descriptor # (agent/error_surface.py) reads a rejected OAuth token as a retryable # "Provider error" and offers Retry instead of a re-login. @@ -795,6 +816,9 @@ def max_retries_exhausted_result( else: agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}") _vlines(agent, f" 💀 Final error: {_final_summary}") + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + _vlines(agent, f" 💡 {_welcome_hint}") # SSE stream-drop (e.g. "Network connection lost"): usually a proxy/CDN cutting a very # large tool call mid-response. @@ -849,6 +873,8 @@ def max_retries_exhausted_result( ) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" if _is_thinking_timeout: # Thinking-timeout guidance overrides stream-drop guidance, which would wrongly # suggest splitting large file writes. diff --git a/apps/desktop/electron/guest-onboarding-flag.test.ts b/apps/desktop/electron/guest-onboarding-flag.test.ts new file mode 100644 index 0000000000000..95954fbb9acdc --- /dev/null +++ b/apps/desktop/electron/guest-onboarding-flag.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' +import { buildSpawnCommand } from './remote-lifecycle' + +test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => { + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true) + assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true) + + assert.equal(guestOnboardingEnabled([], {}), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: 'true' }), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '0' }), false) + assert.equal(guestOnboardingEnabled(['electron', '.', '--local'], { HERMES_GUEST_ONBOARDING: '' }), false) +}) + +test('desktopBackendSpawnEnv stamps the launch decision last and never lets an inherited value leak', () => { + const base = { + HERMES_HOME: '/tmp/home', + HERMES_DESKTOP: '1', + HERMES_GUEST_ONBOARDING: '1', + PATH: '/usr/bin' + } + + const on = desktopBackendSpawnEnv({ ...base, HERMES_GUEST_ONBOARDING: '0' }, true) + assert.equal(on.HERMES_GUEST_ONBOARDING, '1') + + const off = desktopBackendSpawnEnv(base, false) + assert.equal(off.HERMES_GUEST_ONBOARDING, '0', 'a stray inherited "1" must not turn the free tier on') + + for (const env of [on, off]) { + assert.equal(env.HERMES_HOME, base.HERMES_HOME) + assert.equal(env.HERMES_DESKTOP, base.HERMES_DESKTOP) + assert.equal(env.PATH, base.PATH) + } +}) + +test('remote SSH spawn command carries HERMES_GUEST_ONBOARDING=1 only when the launch decided on', () => { + const on = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: true }) + assert.match(on, /exec env HERMES_DESKTOP=1 HERMES_GUEST_ONBOARDING=1 /) + + const off = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: false }) + assert.match(off, /exec env HERMES_DESKTOP=1 /) + assert.doesNotMatch(off, /HERMES_GUEST_ONBOARDING/) + + const unset = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log' }) + assert.doesNotMatch(unset, /HERMES_GUEST_ONBOARDING/) +}) diff --git a/apps/desktop/electron/guest-onboarding.ts b/apps/desktop/electron/guest-onboarding.ts new file mode 100644 index 0000000000000..0610dd5c47051 --- /dev/null +++ b/apps/desktop/electron/guest-onboarding.ts @@ -0,0 +1,23 @@ +// The Nous free tier is gated by ONE launch-time decision. The Python backend +// reads HERMES_GUEST_ONBOARDING and treats exactly "1" as on; the desktop +// decides once at launch (env or `--guest-onboarding` argv) and stamps that +// answer onto every backend it spawns, so the app and its backends can never +// disagree about whether the free tier is live. + +export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING' +export const GUEST_ONBOARDING_FLAG = '--guest-onboarding' + +export function guestOnboardingEnabled( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): boolean { + return env[GUEST_ONBOARDING_ENV] === '1' || argv.includes(GUEST_ONBOARDING_FLAG) +} + +// Outermost wrapper for a backend spawn env: the flag is written LAST so no +// earlier spread (process.env, backend.env) can resurrect a stray value, and +// "off" is an explicit '0' rather than an absent key so a '1' inherited from +// the parent's environment cannot leak into a backend the launch decided off. +export function desktopBackendSpawnEnv(base: NodeJS.ProcessEnv, guestOnboarding: boolean): NodeJS.ProcessEnv { + return { ...base, [GUEST_ONBOARDING_ENV]: guestOnboarding ? '1' : '0' } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d0c2e18513a5a..fdf7239b7b5fc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -202,6 +202,7 @@ import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gatewa import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' +import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' import { readAndConsumeHandoffResult } from './handoff-result' import { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, @@ -903,6 +904,9 @@ const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' // nobody to answer a modal, so the active-work confirmation would hang the // caller instead of letting the process exit. Force quits set this. const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1' +// Nous free tier gate, decided ONCE here and stamped onto every backend spawn +// (desktopBackendSpawnEnv) and the renderer (hermes:launch-flags). +const GUEST_ONBOARDING = guestOnboardingEnabled() const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -10737,6 +10741,9 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc probeReuseProof: sshProbeReuseProof, adoptServedToken: adoptServedDashboardToken, rememberLog: sshRememberLog, + // Same launch-time free-tier decision the local spawns get; the POSIX + // spawn command adds HERMES_GUEST_ONBOARDING=1 only when this is on. + guestOnboarding: GUEST_ONBOARDING, signal: lease.signal }) } catch (error: any) { @@ -12666,25 +12673,28 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, - env: { - ...process.env, - HERMES_HOME, - ...backend.env, - // Pin the gateway's tool/terminal cwd to the same directory we chose for - // the child process. Inherited TERMINAL_CWD (or a stale config bridge) - // can still point at the install dir even when spawn cwd is home. - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - // Exact parent identity lets the backend self-exit after an unclean - // Desktop death without mistaking a reused PID for its owner. If the - // optional marker probe fails, retain legacy PID-only tracking. - ...parentIdentityEnv, - HERMES_WEB_DIST: webDist, - ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) - }, + env: desktopBackendSpawnEnv( + { + ...process.env, + HERMES_HOME, + ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + // Exact parent identity lets the backend self-exit after an unclean + // Desktop death without mistaking a reused PID for its owner. If the + // optional marker probe fails, retain legacy PID-only tracking. + ...parentIdentityEnv, + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) + }, + GUEST_ONBOARDING + ), shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -13083,30 +13093,33 @@ async function startHermes() { backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, - env: { - ...process.env, - // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() - // resolves to the SAME location our resolveHermesHome() picked. Without - // this pin, Python falls back to ~/.hermes on every platform — fine on - // mac/linux (where our default matches), but on Windows our default is - // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. - // Mismatch would split config / sessions / .env / logs across two - // directories. install.ps1 sets HERMES_HOME via setx; the desktop - // can't reliably do that, so we set it inline for every spawn. - HERMES_HOME, - ...backend.env, - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - // Exact parent identity lets the backend self-exit after an unclean - // Desktop death without mistaking a reused PID for its owner. If the - // optional marker probe fails, retain legacy PID-only tracking. - ...parentIdentityEnv, - HERMES_WEB_DIST: webDist, - ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) - }, + env: desktopBackendSpawnEnv( + { + ...process.env, + // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() + // resolves to the SAME location our resolveHermesHome() picked. Without + // this pin, Python falls back to ~/.hermes on every platform — fine on + // mac/linux (where our default matches), but on Windows our default is + // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. + // Mismatch would split config / sessions / .env / logs across two + // directories. install.ps1 sets HERMES_HOME via setx; the desktop + // can't reliably do that, so we set it inline for every spawn. + HERMES_HOME, + ...backend.env, + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + // Exact parent identity lets the backend self-exit after an unclean + // Desktop death without mistaking a reused PID for its owner. If the + // optional marker probe fails, retain legacy PID-only tracking. + ...parentIdentityEnv, + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) + }, + GUEST_ONBOARDING + ), shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -17202,7 +17215,8 @@ ipcMain.on('hermes:translucency:support', event => { // only strips internal flags. ipcMain.on('hermes:launch-flags', event => { event.returnValue = { - localModels: process.argv.includes('--local') || process.platform === 'win32' || process.platform === 'darwin' + localModels: process.argv.includes('--local') || process.platform === 'win32' || process.platform === 'darwin', + guestOnboarding: GUEST_ONBOARDING } }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a8ebb1d71f1d2..09519f21c8cf2 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // Launch-flag fact: the app was started with --local, so the renderer may // show the local-models surfaces. Static for the window's lifetime. localModelsEnabled: launchFlags?.localModels === true, + // Launch-flag fact: the Nous free tier is on for this launch + // (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only; the same + // decision is stamped onto every backend the app spawns. + guestOnboardingEnabled: launchFlags?.guestOnboarding === true, getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index a9b2d9b4fd1bb..cf38b4752d8fb 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -1065,7 +1065,7 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) { const dashCmd = `ulimit -n ${REMOTE_NOFILE_SOFT_LIMIT} 2>/dev/null || true; ` + - `exec env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}` + `exec env HERMES_DESKTOP=1${opts.guestOnboarding === true ? ' HERMES_GUEST_ONBOARDING=1' : ''} ${hermes} ${profileArgs}${subCmd}` const detachedShell = `eval "exec $1>&-"; ${dashCmd} > ${logPath} 2>&1 & echo $!` const detachedSpawn = `child=$("$(command -v setsid || echo nohup)" sh -c ${shq(detachedShell)} hermes-update-child "$1" & echo $!)` @@ -1171,7 +1171,15 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT async function spawnRemoteDashboard( ssh, - { hermesPath, profile, token, ownershipId, hermesHome = '~/.hermes', assertInstallClear = async () => {} } + { + hermesPath, + profile, + token, + ownershipId, + hermesHome = '~/.hermes', + guestOnboarding = false, + assertInstallClear = async () => {} + } ) { if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) { const err: any = new Error( @@ -1241,6 +1249,7 @@ async function spawnRemoteDashboard( tokenFilePath, logPath, hermesHome, + guestOnboarding, ownershipId, reservationNonce: spawnNonce, lockMetadata: { @@ -1390,6 +1399,7 @@ async function connect(deps) { adoptServedToken, rememberLog = () => {}, readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, + guestOnboarding = false, signal } = deps @@ -1543,6 +1553,7 @@ async function connect(deps) { token: spawnToken, ownershipId, hermesHome, + guestOnboarding, assertInstallClear: () => assertRemoteInstallUpdateClear(ssh, hermesHome) }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts new file mode 100644 index 0000000000000..7d655fe74e653 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts @@ -0,0 +1,105 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useStatusSnapshot } from '@/app/shell/hooks/use-status-snapshot' +import { getStatus } from '@/hermes' +import { $setupReadyTick } from '@/store/live-sync' + +import { handleLifecycleEvent } from './lifecycle' +import type { GatewayEventContext } from './types' + +vi.mock(import('@/hermes'), async importOriginal => ({ + ...(await importOriginal()), + getStatus: vi.fn() +})) + +type GatewayRequester = (method: string, params?: Record) => Promise + +function setupReadyContext(fromActiveSource: boolean): GatewayEventContext { + const payload = { + error: '', + finished_at: 1_700_000_100, + free_tier: true, + has_identity: true, + inference_provider: 'nous', + other_providers: false, + provider_configured: true + } + + return { + deps: {} as GatewayEventContext['deps'], + event: { payload, type: 'setup.ready' }, + explicitSid: '', + fromActiveSource: () => fromActiveSource, + isActiveEvent: false, + occurredAt: 1_700_000_100, + payload: payload as GatewayEventContext['payload'], + scheduleConfigRefresh: vi.fn(), + sessionId: null + } +} + +async function flushAsync() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +/** Mount the status snapshot on an open gateway and return its requester with + * the open-time readiness round already consumed. */ +async function mountedStatusSnapshot() { + const requestGateway = vi.fn( + async (method: string) => (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + renderHook(() => useStatusSnapshot('open', requestGateway as unknown as GatewayRequester)) + await flushAsync() + requestGateway.mockClear() + vi.mocked(getStatus).mockClear() + + return requestGateway +} + +function callsTo(requestGateway: ReturnType, method: string) { + return requestGateway.mock.calls.filter(([called]) => called === method) +} + +describe('handleLifecycleEvent setup.ready', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.mocked(getStatus) + .mockReset() + .mockResolvedValue({} as never) + $setupReadyTick.set(0) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('claims the event and triggers one free-tier refresh plus one readiness evaluation from the active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(true))).toBe(true) + await flushAsync() + + expect(callsTo(requestGateway, 'free_tier.status')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.runtime_check')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.status')).toHaveLength(1) + // The push is a readiness seam, not a status tick. + expect(getStatus).not.toHaveBeenCalled() + }) + + it('claims but ignores setup.ready from a non-active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(false))).toBe(true) + await flushAsync() + + expect(requestGateway).not.toHaveBeenCalled() + expect($setupReadyTick.get()).toBe(0) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index 2395498d9402d..16633910dceb8 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -6,6 +6,7 @@ import { notifyPetChanged, notifyPlatformsChanged, notifySessionsChanged, + notifySetupReady, type PetChangeMeta, setChangeEventsAvailable } from '@/store/live-sync' @@ -17,7 +18,7 @@ import { ingestBackendSkin } from '@/themes/backend-sync' import type { GatewayEventContext } from './types' -/** gateway.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ +/** gateway.ready / setup.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, fromActiveSource } = ctx @@ -32,6 +33,20 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'setup.ready') { + // The boot bootstrap (hermes_cli/free_tier_bootstrap.py) resolved the + // free-tier identity and the inference route, and broadcast once. The + // payload is only a hint — the status snapshot re-reads `setup.status` / + // `setup.runtime_check` / `free_tier.status` through its own scoped + // requester so the chip, strip and onboarding react now rather than on + // the next ambient tick. Only the active source's boot matters here. + if (fromActiveSource()) { + notifySetupReady() + } + + return true + } + if (event.type === 'skin.changed') { // A runtime skin switch (Hermes activating an authored skin, or `/skin` // on another surface). Only the active source+profile's change repaints. diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index d333dbf3dca99..c30a5816ac4d3 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -331,7 +331,7 @@ function freeTierView(billing: BillingStateResponse): BillingView { return { notice: { action: { label: 'Sign in', onSelect: openFreeTierSignIn }, - message: 'Sign in to keep your connectors and unlock more.', + message: 'Sign in with a Nous account to unlock more models and tools.', title: "You're on the Nous free tier", tone: 'info' }, diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index 820cc285c6f8d..f05979d30657b 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getStatus } from '@/hermes' +import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' import { deferred } from '../../../test/deferred' @@ -25,6 +26,7 @@ beforeEach(() => { vi.mocked(getStatus) .mockReset() .mockResolvedValue({} as never) + $setupReadyTick.set(0) }) afterEach(() => { @@ -200,13 +202,15 @@ describe('useStatusSnapshot', () => { renderHook(() => useStatusSnapshot('open', requestGateway)) await flushAsync() - // Three legs per round: setup.status, setup.runtime_check, free_tier.status. + // Open runs the readiness legs once: setup.status, setup.runtime_check, free_tier.status. + expect(getStatus).toHaveBeenCalledOnce() expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) + expect(getStatus).toHaveBeenCalledOnce() expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { @@ -218,11 +222,55 @@ describe('useStatusSnapshot', () => { await act(async () => { await vi.advanceTimersByTimeAsync(59_999) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(3) + expect(getStatus).toHaveBeenCalledOnce() await act(async () => { await vi.advanceTimersByTimeAsync(1) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(6) + + // The periodic tick is status-only: readiness and the free-tier verdict + // arrive by `setup.ready` push plus the one-shots on open and on return. + expect(getStatus).toHaveBeenCalledTimes(2) + expect(requestGatewayMock).toHaveBeenCalledTimes(3) + }) + + it('re-reads readiness and the free-tier verdict once per setup.ready, off the status tick', async () => { + const requestGatewayMock = vi.fn( + async (method: string) => + (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('open', requestGateway)) + await flushAsync() + requestGatewayMock.mockClear() + vi.mocked(getStatus).mockClear() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + const methods = requestGatewayMock.mock.calls.map(([method]) => method) + expect(methods.filter(method => method === 'free_tier.status')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.runtime_check')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.status')).toHaveLength(1) + expect(getStatus).not.toHaveBeenCalled() + }) + + it('ignores setup.ready while the gateway is not open', async () => { + const requestGatewayMock = vi.fn(async () => ({}) as never) + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('connecting', requestGateway)) + await flushAsync() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + expect(requestGatewayMock).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index c0917e930977b..bf8b3b6b1634b 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { getStatus } from '@/hermes' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' +import { $setupReadyTick } from '@/store/live-sync' import type { StatusResponse } from '@/types/hermes' // Statusbar health is ambient chrome, not live data — nothing the user acts on @@ -39,33 +40,65 @@ export function useStatusSnapshot( const scheduleRefresh = () => { if (!cancelled) { - timer = window.setTimeout(() => void refresh(), REFRESH_MS) + timer = window.setTimeout(() => void refresh({ readiness: false }), REFRESH_MS) } } - const refresh = async () => { + const isViewed = () => // macOS commonly leaves an occluded BrowserWindow `visible`; focus is // the missing signal that prevents status + readiness RPCs while the // user is working in another app. - if (document.visibilityState !== 'visible' || !document.hasFocus()) { + document.visibilityState === 'visible' && document.hasFocus() + + // Inference readiness + the free-tier verdict. Not on the periodic tick: + // both change only at seams the backend announces (`setup.ready` at boot) + // or that this window crosses (open, return from another app), so they + // run once per seam instead of every 60s. + const refreshReadiness = async () => { + if (gatewayState !== 'open') { + return + } + + // The free-tier verdict is a local, zero-network read that writes + // straight to its own store and swallows its failures — nothing here + // waits on it or reads the result. + const [inferenceResult] = await Promise.allSettled([ + evaluateRuntimeReadiness(requestGateway), + refreshFreeTierStatus(requestGateway) + ]) + + if (cancelled || inferenceResult.status !== 'fulfilled') { + return + } + + const inference = inferenceResult.value + + if (inference.source !== 'fallback') { + // runtime_check/setup_status returned an authoritative boolean. + // A fallback means both RPCs failed or returned no boolean, so it + // is a transient/unknown transport state, not proof that inference + // became unconfigured. Keep the last authoritative result instead + // of flashing "Inference not ready" during a gateway flap. + setInferenceStatus(inference) + setFreeTierRoute(inference.freeTier) + } + } + + const refresh = async ({ readiness }: { readiness: boolean }) => { + if (!isViewed()) { scheduleRefresh() return } try { - // Wait for both legs before scheduling the next refresh. setInterval + // Wait for every leg before scheduling the next refresh. setInterval // allowed a slow runtime check to overlap with later polls, which // multiplied load on an already-busy gateway and let stale failures // race newer healthy results. - const [statusResult, inferenceResult] = await Promise.allSettled([ + const [statusResult] = await Promise.allSettled([ getStatus(), - gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null), - // The free-tier verdict is a local, zero-network read, so it rides - // this cadence rather than earning a poll of its own. It writes - // straight to its own store and swallows its failures — nothing here - // waits on it or reads the result. - gatewayState === 'open' ? refreshFreeTierStatus(requestGateway) : Promise.resolve(null) + readiness ? refreshReadiness() : Promise.resolve() ]) if (cancelled) { @@ -75,43 +108,34 @@ export function useStatusSnapshot( if (statusResult.status === 'fulfilled') { setStatusSnapshot(statusResult.value) } - - if (inferenceResult.status === 'fulfilled') { - const inference = inferenceResult.value - - if (inference === null) { - setInferenceStatus(null) - } else if (inference.source !== 'fallback') { - // runtime_check/setup_status returned an authoritative boolean. - // A fallback means both RPCs failed or returned no boolean, so it - // is a transient/unknown transport state, not proof that inference - // became unconfigured. Keep the last authoritative result instead - // of flashing "Inference not ready" during a gateway flap. - setInferenceStatus(inference) - setFreeTierRoute(inference.freeTier) - } - } } finally { scheduleRefresh() } } const onReturn = () => { - if (document.visibilityState === 'visible' && document.hasFocus() && !cancelled) { + if (isViewed() && !cancelled) { if (timer !== undefined) { window.clearTimeout(timer) } - void refresh() + void refresh({ readiness: true }) } } + // `setup.ready` (routed by the gateway-event lifecycle handler for the + // active source only) says the boot bootstrap just settled the route: one + // readiness round now, so the chip/strip/onboarding move at once. Rides + // outside the status tick so it neither resets nor waits on the timer. + const unsubscribeSetupReady = $setupReadyTick.listen(() => void refreshReadiness()) + document.addEventListener('visibilitychange', onReturn) window.addEventListener('focus', onReturn) - void refresh() + void refresh({ readiness: true }) return () => { cancelled = true + unsubscribeSetupReady() document.removeEventListener('visibilitychange', onReturn) window.removeEventListener('focus', onReturn) diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx index 8918be7a855d1..0d67ac637010f 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx @@ -74,7 +74,7 @@ describe('FreeTierSignInDialog', () => { }) await waitFor(() => expect(screen.getByText('Signed in as someone@example.com')).toBeTruthy()) - expect(screen.getByText('Your connectors are kept.')).toBeTruthy() + expect(screen.getByText('Your account now carries inference and tools.')).toBeTruthy() expect(screen.getByText('Hermes-4-405B')).toBeTruthy() }) }) diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx index 2a765e49b4134..1983739b8a847 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx @@ -147,7 +147,7 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp {state.status === 'completed' && ( diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0089b100df37d..c5538e3bc5241 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -323,6 +323,10 @@ declare global { /** Launch flag: the app was started with --local, enabling the * local-models GUI surfaces. Absent/false = every local surface hides. */ localModelsEnabled?: boolean + /** Launch flag: the Nous free tier is on for this launch + * (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only fact the + * main process also stamps onto every backend it spawns. */ + guestOnboardingEnabled?: boolean setTranslucency?: (payload: TranslucencyState) => void setKeepAwake?: (on: boolean) => void setDisableF12?: (blocked: boolean) => void diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0948fa36181d1..959dad2026c91 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3276,7 +3276,7 @@ export const en: Translations = { freeTier: { providerRowTitle: 'Nous · free tier', - providerRowPitch: 'Sign in to keep your connectors and unlock more.', + providerRowPitch: 'Sign in with a Nous account to unlock more models and tools.', readyTitle: 'Hermes is ready.', readyCaption: 'Free · connectors included', begin: 'Begin', @@ -3288,7 +3288,7 @@ export const en: Translations = { dismiss: 'Dismiss', statusLabel: model => `Nous · free tier · ${model}`, signIn: 'Sign in', - signInHeading: 'Sign in to keep your connectors and unlock more.', + signInHeading: 'Sign in with a Nous account to unlock more models and tools.', settingUp: 'Setting up free inference…', codeBody: 'Enter this code in your browser to finish signing in.', copyLink: 'Copy link', @@ -3298,7 +3298,7 @@ export const en: Translations = { finishingBody: 'Approved in the browser. Collecting your account tokens.', signedInAs: email => `Signed in as ${email}`, signedIn: 'Signed in.', - connectorsKept: 'Your connectors are kept.', + completedBody: 'Your account now carries inference and tools.', defaultModel: 'Default model', change: 'Change', done: 'Done', @@ -3310,7 +3310,7 @@ export const en: Translations = { supersededBody: 'A newer sign-in code replaced this one.', timedOutHeading: 'Sign-in timed out', timedOutBody: 'The code was not used in time. You are still on the free tier.', - retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.', + retiredBody: 'This free-tier identity was already used or expired; a new one is set up on the next start.', errorBody: 'Sign-in did not complete; run it again.', alreadySignedInHeading: 'Already signed in.', alreadySignedInBody: 'This Hermes is already signed in to a Nous account.' diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 0ebca0ec6a284..3820b8b181f0a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2829,7 +2829,7 @@ export interface Translations { finishingBody: string signedInAs: (email: string) => string signedIn: string - connectorsKept: string + completedBody: string defaultModel: string change: string done: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ae9a80e520df4..ff405d9d97a4b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3414,45 +3414,45 @@ export const zh: Translations = { // Not yet translated — English fallbacks so the free-tier surfaces stay // readable until a zh pass lands. freeTier: { - providerRowTitle: 'Nous · free tier', - providerRowPitch: 'Sign in to keep your connectors and unlock more.', - readyTitle: 'Hermes is ready.', - readyCaption: 'Free · connectors included', - begin: 'Begin', - signInInstead: 'Sign in with a Nous account instead', - otherProviders: 'Other providers', - stripTitle: 'Free Nous inference and connectors are now available.', - stripBody: 'Open the model picker to try them, or sign in with a Nous account.', - openModelPicker: 'Open model picker', - dismiss: 'Dismiss', - statusLabel: model => `Nous · free tier · ${model}`, - signIn: 'Sign in', - signInHeading: 'Sign in to keep your connectors and unlock more.', - settingUp: 'Setting up free inference…', - codeBody: 'Enter this code in your browser to finish signing in.', - copyLink: 'Copy link', - doNotShare: 'Do not share this code.', - waiting: 'Waiting for sign-in…', - finishingHeading: 'Finishing sign-in…', - finishingBody: 'Approved in the browser. Collecting your account tokens.', - signedInAs: email => `Signed in as ${email}`, - signedIn: 'Signed in.', - connectorsKept: 'Your connectors are kept.', - defaultModel: 'Default model', - change: 'Change', - done: 'Done', - notNow: 'Not now', - tryAgain: 'Try again', - startAgain: 'Start again', - didNotComplete: 'Sign-in did not complete', - rejectedBody: 'Sign-in was rejected in the browser. You are still on the free tier.', - supersededBody: 'A newer sign-in code replaced this one.', - timedOutHeading: 'Sign-in timed out', - timedOutBody: 'The code was not used in time. You are still on the free tier.', - retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.', - errorBody: 'Sign-in did not complete; run it again.', - alreadySignedInHeading: 'Already signed in.', - alreadySignedInBody: 'This Hermes is already signed in to a Nous account.' + providerRowTitle: 'Nous · 免费层', + providerRowPitch: '登录 Nous 账户以解锁更多模型和工具。', + readyTitle: 'Hermes 已就绪。', + readyCaption: '免费 · 含连接器', + begin: '开始', + signInInstead: '改为登录 Nous 账户', + otherProviders: '其他提供方', + stripTitle: '免费的 Nous 推理和连接器现已可用。', + stripBody: '打开模型选择器试用,或登录 Nous 账户。', + openModelPicker: '打开模型选择器', + dismiss: '关闭', + statusLabel: model => `Nous · 免费层 · ${model}`, + signIn: '登录', + signInHeading: '登录 Nous 账户以解锁更多模型和工具。', + settingUp: '正在设置免费推理…', + codeBody: '在浏览器中输入此代码以完成登录。', + copyLink: '复制链接', + doNotShare: '请勿分享此代码。', + waiting: '等待登录…', + finishingHeading: '正在完成登录…', + finishingBody: '已在浏览器中批准。正在获取账户令牌。', + signedInAs: email => `已登录为 ${email}`, + signedIn: '已登录。', + completedBody: '现在由你的账户提供推理和工具。', + defaultModel: '默认模型', + change: '更改', + done: '完成', + notNow: '暂不', + tryAgain: '重试', + startAgain: '重新开始', + didNotComplete: '登录未完成', + rejectedBody: '登录在浏览器中被拒绝。你仍在免费层。', + supersededBody: '一个更新的登录代码替换了此代码。', + timedOutHeading: '登录超时', + timedOutBody: '代码未在有效期内使用。你仍在免费层。', + retiredBody: '此免费层身份已被使用或已过期;下次启动时会重新设置。', + errorBody: '登录未完成;请重试。', + alreadySignedInHeading: '已登录。', + alreadySignedInBody: '此 Hermes 已登录 Nous 账户。' }, modelPicker: { diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts index e07c150842684..cf8d9665ea910 100644 --- a/apps/desktop/src/lib/runtime-readiness.ts +++ b/apps/desktop/src/lib/runtime-readiness.ts @@ -1,5 +1,12 @@ export interface SetupStatusSnapshot { provider_configured?: boolean + /** Additive launch-profile fields (newer backends only; absent on older + * ones). Carried for consumers that read the record — readiness itself + * still keys on `provider_configured` + `setup.runtime_check`. */ + ready?: boolean + free_tier?: boolean + other_providers?: boolean + inference_provider?: string } export interface RuntimeCheckSnapshot { diff --git a/apps/desktop/src/store/live-sync.ts b/apps/desktop/src/store/live-sync.ts index 844c4d39f2b21..d06bc35c0e488 100644 --- a/apps/desktop/src/store/live-sync.ts +++ b/apps/desktop/src/store/live-sync.ts @@ -33,6 +33,12 @@ export interface PetChangeMeta { export const $petChange = atom<{ meta?: PetChangeMeta; tick: number }>({ tick: 0 }) +/** `setup.ready` — the boot bootstrap (free-tier identity + provider resolution) + * finished, so inference readiness and the free-tier verdict may have just + * changed. One-shot: the status snapshot re-reads both legs once instead of + * waiting for its next ambient tick. */ +export const $setupReadyTick = atom(0) + export function setChangeEventsAvailable(available: boolean): void { $changeEventsAvailable.set(available) } @@ -57,6 +63,10 @@ export function notifyPairingChanged(): void { $pairingChangeTick.set($pairingChangeTick.get() + 1) } +export function notifySetupReady(): void { + $setupReadyTick.set($setupReadyTick.get() + 1) +} + /** Reset on gateway wipe/reconnect — a new backend re-advertises capability on * its own gateway.ready, and stale ticks must not fire refreshes into stores * the wipe just cleared. */ diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 5c15a264a565c..71814d7c9ec4d 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -218,10 +218,16 @@ def _restore_session_model_override(self, session_key: str, snapshot: dict) -> N state.conversation.model_override = None self._evict_cached_agent(session_key) - def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: - """Return True if *agent_model* matches an active /model session override.""" + def _is_intentional_model_switch(self, session_key: str, agent: Any, config_model: str) -> bool: + """True when *agent* running a model other than *config_model* is deliberate: a /model session + override names that model, or the Nous gateway moved the session off the ``nous/welcome`` + alias that *config_model* still carries (``anon_auth.apply_model_switch``).""" override = self._session_model_override(session_key) - return override is not None and override.get("model") == agent_model + if override is not None and override.get("model") == agent.model: + return True + # Exactly the recorded move (alias -> backing): a later fallback onto some other model is + # ordinary drift and still evicts. + return getattr(agent, "_nous_model_switch", None) == (config_model, agent.model) def _release_running_agent_state( self, session_key: str, *, run_generation: Optional[int] = None diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index fb0fc4995cee4..41f8a55ee6913 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -747,14 +747,16 @@ def _free_tier_startup_line(self) -> Optional[str]: 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. + # Persisted state only. The free-tier check reads auth.json; it runs FIRST so the resolver + # is only consulted when a free-tier identity already exists and its own free-tier rung + # (which may mint on a fresh install, NS-829) answers from that identity without a network + # call. No token refresh at boot either way. 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 + if resolve_provider("auto") != "nous": + return None except Exception as exc: logger.debug("Free tier startup line skipped: %s", exc) return None diff --git a/gateway/run_startup.py b/gateway/run_startup.py index 29fd2c252fd31..8ce482df1eb88 100644 --- a/gateway/run_startup.py +++ b/gateway/run_startup.py @@ -90,6 +90,13 @@ async def _drain_startup_restore_queue(self) -> int: drained += 1 return drained + @staticmethod + def _start_free_tier_bootstrap() -> None: + """One bootstrap per process. `run_bootstrap` already records its own failure in the boot record + and never raises, so this is a plain call; it exists as a method so tests can seam it.""" + from hermes_cli.free_tier_bootstrap import run_bootstrap + run_bootstrap(announce=False) + def _start_startup_warmup(self) -> None: """Kick off the boot turn-machinery warm-up so it overlaps the network-bound platform connects; ``_finish_startup_restore`` awaits it (bounded).""" @@ -1317,6 +1324,12 @@ async def start(self) -> bool: if self._start_check_access_policy(): return True await self._start_recover_previous_run() + # The gateway is a boot owner of the Nous free tier, beside `cmd_chat` and `hermes serve`: every + # demand-time site (provider resolution, /login, the connector token) is a read that needs the + # identity to already exist. Blocking here, before any adapter connects, is what keeps a fast + # first DM from arriving with nothing to resolve. With the launch gate unset this is a local + # inventory and no network. + await asyncio.get_running_loop().run_in_executor(None, self._start_free_tier_bootstrap) # Serialize startup restore against inbound: adapters receive as soon as they connect, so inbound # queues until every synthetic resume turn has finished. self._startup_restore_in_progress = True diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 1f222be9f9060..472b008a7cbbb 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -3275,7 +3275,7 @@ def _run_agent_evict_on_fallback(self, turn_ctx: TurnContext) -> None: _agent_provider = getattr(_agent, 'provider', '') or '' if _agent_provider and _agent_provider not in _AGGREGATOR_PROVIDERS: _cfg_model = normalize_model_for_provider(_cfg_model, _agent_provider) - if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model): + if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent, _cfg_model): self._evict_cached_agent(session_key) async def _run_agent_finalize_streaming_tts(self, turn_ctx: TurnContext, adapter: Any) -> None: diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 0953877f92bed..5460034dccdb2 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -1,12 +1,14 @@ -"""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. +"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider. + +The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only +while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous +account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived +JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``; it +becomes ``active_provider`` only when the bootstrap's inventory found nothing else usable, so an +install with its own key keeps that key for inference and uses the identity for connectors only. In +the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the +implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter +pool, a logged-in ``active_provider``) beats it, and the ladder never creates one. 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 @@ -25,13 +27,12 @@ import logging import os -import threading import time from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, Optional from hermes_cli.auth_constants import ( - AuthError, DEFAULT_NOUS_PORTAL_URL, _decode_jwt_claims, httpx) + AuthError, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_WELCOME_URL, _decode_jwt_claims, httpx) logger = logging.getLogger("hermes_cli.auth") @@ -43,9 +44,11 @@ # 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" +# Launch gate for the whole free tier while it is pre-GA: exactly "1" turns it on for this process +# (CLI, gateway, serve backend alike); anything else leaves every surface behaving as if the free +# tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to +# config.yaml or .env, never shown in setup. Deleted at GA together with this comment. +GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING" 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" @@ -66,18 +69,11 @@ 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 + """The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default + True) has not switched it off. The only place either is read.""" + if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1": + return False try: from hermes_cli.config import load_config_readonly nous_cfg = load_config_readonly().get("nous") @@ -226,7 +222,11 @@ def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> 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")) + # NAS names the welcome host on every exchange; absent (older NAS) or outside the allowlist + # (a staging host without NOUS_INFERENCE_BASE_URL set), the literal stands in. Never the paid + # host: the gateway cross-refuses an anonymous JWT there. + inference_url = (_validate_nous_inference_url_from_network(exchanged.get("inference_base_url")) + or DEFAULT_NOUS_WELCOME_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) @@ -235,8 +235,7 @@ def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> 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 + state["inference_base_url"] = inference_url for key in ("user_id", "org_id"): if exchanged.get(key): state[key] = exchanged[key] @@ -256,11 +255,17 @@ def _shared_identity_key(state: Any) -> Optional[str]: 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]: +def _mint_locked( + client: httpx.Client, portal: str, auth_store: Dict[str, Any], *, carries_inference: bool = True, +) -> 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 + next attempt exchanges the stored one instead of minting again). + + ``carries_inference`` decides whether the new identity also becomes ``active_provider``. The + bootstrap passes False when its inventory found another usable provider: the identity exists for + connectors, the user's own provider keeps carrying inference (NS-845 Q1.3).""" + from hermes_cli.auth import _store_provider_state, _save_auth_store from hermes_cli.auth_nous import _write_shared_nous_state minted = mint_guest(client, portal) state: Dict[str, Any] = { @@ -270,36 +275,32 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) "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) + _store_provider_state(auth_store, "nous", state, set_active=carries_inference) _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. +# Per-process memo: one failed mint is enough for a process (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. _mint_failed = False -_forced_new_done = False -def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, Any]: +def _reconcile_and_provision(*, timeout_seconds: float, carries_inference: bool = True) -> Optional[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). + sibling profile's sign-in, and never overwrites it). An adopted free-tier identity claims + ``active_provider`` under the same rule as a mint; an adopted ACCOUNT always does (the user + signed in somewhere on this machine). 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) + _store_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() @@ -307,75 +308,55 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, 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 + shared = _read_shared_nous_state() + if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state): + state = dict(shared) + _store_provider_state( + auth_store, "nous", state, + set_active=carries_inference or not is_guest_state(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) + return _mint_locked(client, portal, auth_store, carries_inference=carries_inference) -def ensure_portal_identity(*, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: +def ensure_portal_identity( + *, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, + carries_inference: bool = True, +) -> 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). + store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already). + + ``explicit`` is required and must be True: the only callers are the boot bootstrap + (``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, and the + dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``, + ``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect + of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2). + + Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile + first, then shared, matching every other Nous path. ``carries_inference=False`` leaves + ``active_provider`` alone (the identity is for connectors; another provider does inference). + Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread. """ - global _mint_failed, _forced_new_done + if not explicit: + raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)") + global _mint_failed 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(): + if _mint_failed 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 + return _reconcile_and_provision( + timeout_seconds=timeout_seconds, carries_inference=carries_inference) + except Exception: + _mint_failed = True + raise def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: @@ -425,6 +406,176 @@ def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None: logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason) +# --- Gateway welcome-tier contract: structured refusals and the model-switch header ------------------ +# +# The inference gateway answers a welcome-tier request it will not serve with a structured 429 +# (``{status, message, reason, retry_after, alternates?, upgrade_url?}``), and a request on the wrong +# host with a 400 (or a 403 while the tier is dark) whose message names the right host. A NAMED +# account that still asks for ``nous/welcome`` is served the id's backing model and told what to +# switch to in the ``x-nous-model-switch`` response header. Every rule for reading those lives here; +# the error classifier and the turn loop only call in. + +MODEL_SWITCH_HEADER = "x-nous-model-switch" +# Fairshare refusal reasons the welcome tier can answer with (api ``FairshareRefusalReason``). +WELCOME_REFUSAL_REASONS = frozenset( + {"model_not_free", "feature_not_free", "at_capacity", "admission_closed", "rate_limited"}) +# Reasons that mean "not on this tier, ever": no retry helps, only a sign-in or another provider. +WELCOME_TIER_GATE_REASONS = frozenset({"model_not_free", "feature_not_free"}) +# Gateway messages (lowercased substrings) for a request on the wrong host or a dark tier. +_WELCOME_ROUTE_REFUSALS = ( + ("anonymous accounts must use", "anon_on_paid_host"), + ("serves anonymous hermes agent accounts only", "named_on_welcome_host"), + ("anonymous accounts are not accepted", "tier_disabled"), +) +_WELCOME_ROUTE_COPY = { + "anon_on_paid_host": "The Nous free tier must use its own inference host ({host}); " + "Hermes is pointed at the paid one. Restart Hermes to re-read the route, " + "or unset NOUS_INFERENCE_BASE_URL if you set it.", + "named_on_welcome_host": "This Nous account must use the Nous Portal inference host, " + "not the free tier's. Run /model and pick the Nous row again.", + "tier_disabled": "The Nous free tier is switched off right now. {signin}", +} +_SIGNIN_CHAT = "Sign in with a Nous account for the full catalog: /login." +_SIGNIN_TERMINAL = "Sign in with a Nous account for the full catalog: `hermes auth upgrade`." + + +def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]: + """The structured welcome-tier refusal in a gateway 429 body, or None for any other shape. + + Returns ``{"reason", "retry_after", "alternates", "upgrade_url"}`` with ``retry_after`` an int + of whole seconds (0 when the gateway sent none) and ``alternates`` a list of model ids. + """ + if not isinstance(body, dict): + return None + reason = body.get("reason") + if not isinstance(reason, str) or reason not in WELCOME_REFUSAL_REASONS: + return None + raw_retry = body.get("retry_after") + try: + retry_after = max(0, int(float(raw_retry))) if raw_retry not in (None, "") else 0 + except (TypeError, ValueError): + retry_after = 0 + raw_alternates = body.get("alternates") + alternates = [str(a) for a in raw_alternates if isinstance(a, str) and a] if isinstance(raw_alternates, list) else [] + upgrade_url = body.get("upgrade_url") + return {"reason": reason, "retry_after": retry_after, "alternates": alternates, + "upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""} + + +def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str: + """User copy for a structured welcome-tier refusal: what happened and the one way forward. + + Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb.""" + signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL + reason = str(refusal.get("reason") or "") + alternates = refusal.get("alternates") or [] + serves = alternates[0] if alternates else GUEST_MODEL + retry = int(refusal.get("retry_after") or 0) + wait = f"Retrying in {retry}s." if retry > 0 else "Try again shortly." + if reason == "model_not_free": + what = f"{model} isn't on the Nous free tier" if model else "That model isn't on the Nous free tier" + return f"{what}; it serves {serves} only. {signin}" + if reason == "feature_not_free": + return f"This feature isn't on the Nous free tier. {signin}" + if reason == "at_capacity": + return f"The Nous free tier is at capacity and briefly paused. {wait} {signin}" + if reason == "admission_closed": + return f"The Nous free tier isn't admitting new sessions right now. {wait} {signin}" + if reason == "rate_limited": + return f"Nous free tier rate limit active \u2014 resets in {retry}s. {signin}" + return f"The Nous free tier refused this request ({reason}). {signin}" + + +def welcome_route_refusal(status: Any, message: Any) -> Optional[str]: + """Which host cross-refusal a gateway 400/403 is, by its message; None for any other error. + + ``"anon_on_paid_host"``: a free-tier JWT reached the paid host. ``"named_on_welcome_host"``: an + account or API key reached the free tier's host. ``"tier_disabled"``: the tier is dark + (``WELCOME_MODE=off``). Each is deterministic for the request: retrying cannot help.""" + if status not in (400, 403): + return None + text = str(message or "").lower() + return next((kind for needle, kind in _WELCOME_ROUTE_REFUSALS if needle in text), None) + + +def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str: + template = _WELCOME_ROUTE_COPY.get(kind) or "The Nous inference gateway refused this route." + return template.format( + host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL) + + +def note_model_switch(agent: Any, headers: Any) -> Optional[str]: + """Record the gateway's ``x-nous-model-switch`` header on *agent* for the next call, if present. + + The header arrives on a NAMED account's response that asked for ``nous/welcome`` (the gateway + served the backing model and billed it normally): the free tier's model no longer belongs in + this install's configuration. Recorded here, applied by :func:`apply_model_switch` between + calls so a response still streaming is never re-labelled under itself. Returns the backing id. + """ + if headers is None: + return None + value = None + try: + value = headers.get(MODEL_SWITCH_HEADER) + if value is None and hasattr(headers, "items"): + value = next((v for k, v in headers.items() if str(k).lower() == MODEL_SWITCH_HEADER), None) + except Exception: + return None + backing = str(value or "").strip() + if not backing: + return None + requested = str(getattr(agent, "model", "") or "") + if backing == requested: + return None + try: + agent._nous_pending_model_switch = (requested, backing) + except Exception: + return None + return backing + + +def apply_model_switch(agent: Any) -> Optional[str]: + """Move *agent* (and the config default, when it still names the switched id) to the backing + model the gateway named. Returns the new model, or None when nothing was pending. + + Runs once per recorded header, between calls. The conversation keeps its history; only the id + the next request carries changes, so a promoted account stops relying on the gateway's reverse + map. The config write is the same one a sign-in completion uses, so ``hermes model`` and the + gateway's config re-read agree with the live session. + """ + pending = getattr(agent, "_nous_pending_model_switch", None) + if not pending: + return None + agent._nous_pending_model_switch = None + requested, backing = pending + if str(getattr(agent, "model", "") or "") != requested: + return None # the session already moved (a /model, a sign-in sweep) + agent.model = backing + # The gateway's cache check compares agent.model with the config default and evicts on a + # mismatch it did not cause; this pair names the move so the check can recognise exactly this + # server-driven switch even when the config write below did not land. + agent._nous_model_switch = (requested, backing) + logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing) + try: + from hermes_cli.config import load_config_readonly + raw = load_config_readonly().get("model") + model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {}) + if str(model_cfg.get("default") or "").strip() == requested: + from hermes_cli.auth import _update_config_for_provider + _update_config_for_provider( + "nous", str(getattr(agent, "base_url", "") or ""), default_model=backing) + logger.info("Config default model moved %s -> %s", requested, backing) + except Exception as exc: + logger.debug("model switch: config default left as is: %s", exc) + status = getattr(agent, "_buffer_status", None) + if callable(status): + try: + status(f"Model is now {backing} (your account's model; {requested} is the free tier's).") + except Exception: + pass + return backing + + # 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. diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index dce6fa8ab404f..c1d677e21755d 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -11,7 +11,7 @@ from hermes_cli.auth_constants import httpx -UPGRADE_START = "Sign in to keep your connectors and unlock more." +UPGRADE_START = "Sign in with a Nous account to unlock more models and tools." 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." @@ -20,8 +20,8 @@ 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_retired": "This free-tier identity was already used or expired; a new one is set up on the next start.", + "account_not_anonymous": "This free-tier identity was already used or expired; a new one is set up on the next start.", "account_busy": "The transfer could not run; run the command again.", } _RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"}) @@ -116,8 +116,7 @@ class Completed(SignInState): ok: ClassVar[bool] = True def _lines(self, no_default: str) -> str: - lines = [f"Signed in as {self.email}. Your connectors are kept." if self.email - else "Signed in. Your connectors are kept."] + lines = [f"Signed in as {self.email}." if self.email else "Signed in."] if self.model_changed: lines.append(f"Default model is now {self.model}." if self.model else no_default) return "\n".join(lines) @@ -288,8 +287,10 @@ def run_sign_in( post_promotion_cancelled = is_cancelled if cancel_wins_after_promotion else (lambda: False) open_scope = scope or contextlib.nullcontext - # Preconditions and the mint run inside the scope; the state they produce is yielded outside - # it, because a scope must never be held across a ``yield``. + # Preconditions run inside the scope; the state they produce is yielded outside it, because a + # scope must never be held across a ``yield``. A sign-in never creates the identity it signs in + # from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the boot + # bootstrap is the only creator, NS-845 Q1.2). precondition_state: Optional[SignInState] = None state: Optional[Dict[str, Any]] = None try: @@ -297,20 +298,12 @@ def run_sign_in( state = _core.current_nous_state() if state and not _core.is_guest_state(state): precondition_state = AlreadySignedIn() - elif not state: - if not _core.guest_enabled(): - precondition_state = Unavailable() - else: - state = _core.ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) - if not _core.is_guest_state(state): - # The free tier is off, or an account appeared mid-flight. - precondition_state = Unavailable() + elif not state or not _core.guest_enabled(): + precondition_state = Unavailable() except Exception as exc: - # An AuthError (gate closed, rate limited) and an ordinary failure -- a cold install whose - # mint cannot reach the portal, an unreadable auth store -- mean the same thing here: there - # is no free tier to sign in from. Both become the one precondition state, so nothing - # escapes ``next()``. KeyboardInterrupt and GeneratorExit are not Exceptions: they still - # propagate. + # An unreadable auth store means the same thing here: there is no free tier to sign in from. + # It becomes the one precondition state, so nothing escapes ``next()``. KeyboardInterrupt + # and GeneratorExit are not Exceptions: they still propagate. precondition_state = Unavailable(detail=str(exc)) if precondition_state is not None: yield precondition_state diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 19f83acfd64be..4424058574339 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -834,12 +834,16 @@ def _persist_provider_state_to_store( def _save_provider_state_to_source( auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], source_path: Optional[Path], ) -> None: - """Persist provider state back to the auth store it was read from.""" + """Persist provider state back to the auth store it was read from. + + A token refresh rewrites credentials, not the user's choice of provider: ``active_provider`` is + left as it is (a Nous free-tier identity refreshed for a connector call must not become the + inference provider of an install that has its own key).""" if source_path is None or _same_path(source_path, _auth_file_path()): - _save_provider_state(auth_store, provider_id, state) + _store_provider_state(auth_store, provider_id, state, set_active=False) _save_auth_store(auth_store) else: - _persist_provider_state_to_store(provider_id, state, source_path, set_active=True) + _persist_provider_state_to_store(provider_id, state, source_path, set_active=False) def mark_provider_active_if_unset(provider_id: str) -> None: @@ -1367,14 +1371,14 @@ def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: return False -def _logged_in_oauth_active_provider() -> Optional[str]: +def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> 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 has_guest() and (skip_free_tier or not guest_enabled()): + return None # the free tier is off (or being discounted), 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: @@ -1432,13 +1436,19 @@ def resolve_provider( requested: Optional[str] = None, *, explicit_api_key: Optional[str] = None, - explicit_base_url: Optional[str] = None) -> str: + explicit_base_url: Optional[str] = None, + skip_free_tier: bool = False) -> str: """Determine which inference provider to use. "auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY -> "openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``; - 7. AWS Bedrock chain; 8. AuthError(no_provider_configured). + 7. Nous free tier when it is on and its identity exists (never created here); + 8. AWS Bedrock chain; 9. AuthError(no_provider_configured). + + ``skip_free_tier`` hides rungs 6-for-a-free-tier-identity and 7: the boot bootstrap asks + "what would carry inference if the free tier did not exist?" to decide whether a fresh identity + may become ``active_provider``. 1. 3. 4. 5. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider 7. 8. Error (no provider configured) See #29285. @@ -1468,7 +1478,7 @@ def resolve_provider( # Determined up front so the env-key tier can warn when an exported key preempts it; the actual # OAuth fallback still happens after the env-key tier. - _oauth_active = _logged_in_oauth_active_provider() + _oauth_active = _logged_in_oauth_active_provider(skip_free_tier=skip_free_tier) env_pid = _env_key_auto_detected(_scoped_key_env, _oauth_active) if env_pid: return env_pid @@ -1486,23 +1496,27 @@ def resolve_provider( _oauth_active) return _oauth_active - # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars); after API-key providers - # so explicit keys always win. + # Nous free tier, when it is on and its identity already exists. This rung sits ABOVE the Bedrock + # chain on purpose: every rung above this line is explicit user intent (CLI creds, config, env + # keys, a sign-in); the boto chain below is implicit host state, and a leftover ~/.aws profile + # used to win the first turn of a fresh install (NS-829). The rung never CREATES the identity: + # that is the boot bootstrap's job (free_tier_bootstrap), so provider resolution stays free of + # network and a fresh install without the bootstrap resolves exactly as upstream does. + if not skip_free_tier: + try: + from hermes_cli.anon_auth import guest_enabled, has_guest + if guest_enabled() and has_guest(): + return "nous" + except Exception as exc: + logger.debug("free tier check during provider resolution skipped: %s", exc) + # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars): implicit host state, + # below explicit keys and below the free tier. try: from agent.bedrock_adapter import has_aws_credentials if has_aws_credentials(): 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, " diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 9678da216cc1e..73ce99b961734 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -52,6 +52,10 @@ def __delattr__(self, name): # Nous Portal defaults DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1" +# The free tier's (anonymous account) inference host. NAS hands it to the client on every token +# exchange (``inference_base_url``); this literal is the fallback when that field is absent or fails +# the host allowlist, because the paid host cross-refuses an anonymous JWT with a 400. +DEFAULT_NOUS_WELCOME_URL = "https://welcome-api.nousresearch.com/v1" DEFAULT_NOUS_CLIENT_ID = "hermes-cli" NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" NOUS_BILLING_MANAGE_SCOPE = "billing:manage" diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index faef27871823a..97057274f3d30 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -21,7 +21,7 @@ from hermes_cli.auth_codex import _pool_entries from hermes_cli.auth_constants import ( _decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_NOUS_CLIENT_ID, - DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, + DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, DEFAULT_NOUS_WELCOME_URL, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS, NOUS_AUTH_PATH_INVOKE_JWT, NOUS_BILLING_MANAGE_SCOPE, NOUS_DEVICE_CODE_SOURCE, NOUS_INFERENCE_INVOKE_SCOPE, NOUS_INVOKE_JWT_MIN_TTL_SECONDS, _nous_err, httpx) @@ -364,7 +364,9 @@ def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]: "scope": src.get("scope") or DEFAULT_NOUS_SCOPE, "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, + # A guest's route defaults to the welcome host: the paid host cross-refuses its JWT. + "inference_base_url": src.get("inference_base_url") or ( + DEFAULT_NOUS_WELCOME_URL if src.get("auth_method") == "anonymous" else DEFAULT_NOUS_INFERENCE_URL), "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, "")}} @@ -805,10 +807,13 @@ def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]: "(host %r or scheme not allowed), using default", portal_url, portal_host) portal_url = DEFAULT_NOUS_PORTAL_URL + # A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there + # (400 naming the welcome host), so an absent or disallowed URL heals to the welcome literal. + from hermes_cli.anon_auth import is_guest_state stored_inference_url = ( _validate_nous_inference_url_from_network( _optional_base_url(state.get("inference_base_url"))) - or DEFAULT_NOUS_INFERENCE_URL) + or (DEFAULT_NOUS_WELCOME_URL if is_guest_state(state) else DEFAULT_NOUS_INFERENCE_URL)) return ( portal_url, stored_inference_url, _nous_inference_env_override() or stored_inference_url, str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)) @@ -976,7 +981,7 @@ def resolve_nous_runtime_credentials( 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: + if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None: raise return _resolve_nous_runtime_credentials( timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index b4410592e6f28..c97d5dd15c2c3 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -755,6 +755,16 @@ def _active_profile_name() -> Optional[str]: return get_active_profile_name() +def _route_model_for_banner(provider: Any) -> str: + """The model the resolved route will actually serve when config names none: today only the Nous + free tier (welcome host -> ``nous/welcome``). Read from the boot record and local auth state; + no network. Empty when nothing resolves, so the caller keeps its "no model configured" line.""" + if (provider or "auto").strip().lower() not in ("auto", "nous"): + return "" + from hermes_cli.anon_auth import GUEST_MODEL, guest_carries_inference + return GUEST_MODEL if guest_carries_inference() else "" + + def _banner_left_lines(model: str, cwd: str, session_id, context_length, provider, *, accent: str, dim: str) -> list: """Model / cwd / session lines under the hero art.""" def _dim_sep(label: str) -> str: @@ -762,6 +772,10 @@ def _dim_sep(label: str) -> str: lines = [] ctx_str = _dim_sep(f"{_format_context_length(context_length)} context") if context_length else "" nous_str = _dim_sep("Nous Research") + if not (model or "").strip(): + # Credentials resolve lazily on the first message; the banner prints first. Ask the route + # the same question so a fresh free-tier install shows its model, not a red "unconfigured". + model = _quiet(lambda: _route_model_for_banner(provider), "") or model if (provider or "").strip().lower() == "moa": # MoA virtual provider: ``model`` is a preset name; show it with its aggregator. agg_label = _quiet(lambda: _moa_aggregator_label(model), "") diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 1dc6432bdf410..93bdd481c70ea 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -199,16 +199,9 @@ def _ensure_runtime_credentials(self) -> bool: 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. + # An explicit provider carries inference. The free-tier identity (for connectors) was + # created by the boot bootstrap before this point, never here; this prints the one-time + # "free tier is here" notice the first time an identity is seen beside an own key. self._maybe_print_free_tier_available_notice() resolved_routing = ( resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), diff --git a/hermes_cli/free_tier_bootstrap.py b/hermes_cli/free_tier_bootstrap.py new file mode 100644 index 0000000000000..eca44e5b2f6e1 --- /dev/null +++ b/hermes_cli/free_tier_bootstrap.py @@ -0,0 +1,157 @@ +"""Serve-start bootstrap for the Nous free tier: the ONE place a free-tier identity is created. + +Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a +daemon thread beside the other background boots; the CLI first-run guard synchronously). It +inventories credentials cheap-first, creates the identity only when the launch gate is open +(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records +the answer in process memory, and tells every connected client with one ``setup.ready`` event. + +Nothing else mints. ``free_tier.status`` and ``setup.status`` read the record; provider resolution +never reaches the portal; a dead credential is replaced by the explicit re-mint in +``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847). +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional + +logger = logging.getLogger("hermes_cli.auth") + +# The desktop's first ``setup.status`` waits this long for the record before falling back to a live +# probe. The mint budget is 5 s (``GUEST_MINT_TIMEOUT_SECONDS``); the rest covers the inventory. +SETUP_READY_WAIT_SECONDS = 8.0 +SETUP_READY_EVENT = "setup.ready" + + +@dataclass(frozen=True) +class SetupRecord: + """What the bootstrap found. One shape for every reader; no version field (renderer and backend + ship together).""" + + provider_configured: bool # some provider can carry inference (free tier included) + inference_provider: str # ``resolve_provider("auto")``'s answer, "" when nothing resolves + free_tier: bool # the identity that exists is the free tier AND the tier is on + has_identity: bool # a Nous identity (free tier or account) is on disk + other_providers: bool # the inventory found something usable BESIDES the free tier + error: str = "" # why the mint did not happen, when it did not; "" otherwise + finished_at: float = field(default_factory=time.time) + + def as_payload(self) -> Dict[str, Any]: + return asdict(self) + + +_lock = threading.Lock() +_record: Optional[SetupRecord] = None +_done = threading.Event() +_started = False + + +def current_record() -> Optional[SetupRecord]: + """The record, or None until the first bootstrap finishes.""" + return _record + + +def wait_for_record(timeout: float = SETUP_READY_WAIT_SECONDS) -> Optional[SetupRecord]: + """Block up to ``timeout`` seconds for a bootstrap that is IN FLIGHT, then return whatever it + produced. Returns None at once when no bootstrap ever started in this process (a bare + ``tui_gateway`` under test, an old serve without the boot hook): the caller falls back to its + live probe instead of paying the wait for nothing.""" + if not _started: + return None + _done.wait(timeout) + return _record + + +def reset_for_tests() -> None: + global _record, _started + with _lock: + _record = None + _started = False + _done.clear() + + +def _inventory_other_providers() -> bool: + """Is anything usable configured BESIDES the free tier? Asks the resolver ladder itself (the + thing that picks the provider for a turn) with the free-tier rung hidden: an explicit key, a + config pin, a sign-in or a host credential answers; nothing else falls through to + ``no_provider_configured``. Not ``_has_any_provider_configured``: that first-run guard counts + keyless catalog providers as "configured" and is True on a blank machine.""" + from hermes_cli.auth import resolve_provider + try: + return resolve_provider("auto", skip_free_tier=True) != "nous" + except Exception as exc: + logger.debug("free tier bootstrap: nothing else carries inference (%s)", exc) + return False + + +def _resolve_inference() -> str: + from hermes_cli.auth import resolve_provider + try: + return str(resolve_provider("auto") or "") + except Exception: + return "" + + +def run_bootstrap(*, announce: bool = True) -> SetupRecord: + """Inventory -> ensure identity (gate permitting) -> resolve inference -> record -> broadcast. + + Runs every boot; only the mint is gated. Idempotent per process: a second call returns the + existing record without touching the portal. Never raises. ``announce=False`` skips the + ``setup.ready`` event: the plain CLI has no client to tell and its stdout is the user's terminal. + """ + global _record, _started + with _lock: + if _record is not None: + return _record + if _started: + _done.wait(SETUP_READY_WAIT_SECONDS) + if _record is not None: + return _record + _started = True + + from hermes_cli import anon_auth + + other = _inventory_other_providers() + error = "" + state: Optional[Dict[str, Any]] = anon_auth.current_nous_state() + if anon_auth.guest_enabled(): + try: + # ``other`` decides whether the mint may also claim ``active_provider`` (NS-845 Q1.3). + state = anon_auth.ensure_portal_identity(explicit=True, carries_inference=not other) + except Exception as exc: + error = str(exc) + logger.info("Nous free tier not set up at boot: %s", exc) + free_tier = bool(state) and anon_auth.is_guest_state(state) and anon_auth.guest_enabled() + record = SetupRecord( + provider_configured=other or free_tier or (bool(state) and not anon_auth.is_guest_state(state)), + inference_provider=_resolve_inference(), + free_tier=free_tier, + has_identity=bool(state), + other_providers=other, + error=error, + ) + with _lock: + _record = record + _done.set() + if announce: + _broadcast(record) + return record + + +def _broadcast(record: SetupRecord) -> None: + try: + from tui_gateway.server import _broadcast_global_event + _broadcast_global_event(SETUP_READY_EVENT, record.as_payload()) + except Exception as exc: # no serve process (plain CLI): nobody to tell + logger.debug("setup.ready not broadcast: %s", exc) + + +def start_background_bootstrap() -> threading.Thread: + """``hermes serve`` entry: run on a daemon thread so a slow portal never delays the socket.""" + thread = threading.Thread(target=run_bootstrap, daemon=True, name="free-tier-bootstrap") + thread.start() + return thread diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ca0cd6f906e22..167830d641dca 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -958,7 +958,9 @@ def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool) def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: - """Check if at least one inference provider is usable. + """Check if at least one inference provider is usable. Never creates one: the Nous free tier + counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``) + is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking. ``strict_profile_scope``: the caller has bound a NAMED profile's home and secret scope and wants an answer for that profile only — launch-process @@ -968,16 +970,6 @@ 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 "" @@ -1053,14 +1045,12 @@ 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. + # Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on. try: - from hermes_cli.anon_auth import ensure_portal_identity - return ensure_portal_identity(blocking=True) is not None + from hermes_cli.anon_auth import guest_enabled, has_guest + return guest_enabled() and has_guest() except Exception as exc: - logger.debug("free tier setup on first run skipped: %s", exc) + logger.debug("free tier check on first run skipped: %s", exc) return False @@ -1705,7 +1695,11 @@ def cmd_chat(args): _warn_retired_xai_models() - # First-run guard: check if any provider is configured before launching + # First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing + # that may create the identity), then the inventory decides whether setup is needed. + from hermes_cli.free_tier_bootstrap import run_bootstrap + + run_bootstrap(announce=False) if not _has_any_provider_configured(): _first_run_setup_guard(args) return diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index a3dbbe35fa5dd..45fc4f47e0ad5 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -292,7 +292,7 @@ def _login_then_offer_gateway(login_args, pconfig): 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.") + print("The Nous free tier is off for this install; 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, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5ffffc9a69ead..72bfab2b4c49d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -235,6 +235,14 @@ def _boot_local_runtime(): threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start() + # Nous free tier: the ONE place its identity is created. Inventories credentials, mints only + # when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and + # broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's + # first setup.status waits on the record (bounded) instead. + from hermes_cli.free_tier_bootstrap import start_background_bootstrap + + start_background_bootstrap() + try: yield finally: diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index fb4476447b2ca..31c21cf49d61b 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -127,6 +127,9 @@ def test_auto_detect_with_aws_credentials(self, monkeypatch): monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + # The Nous free tier counts as a configured provider and sits above the Bedrock chain + # (NS-829); this test's contract is the chain itself, so switch the free tier off. + monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: False) # Mock the auth store to have no active provider with patch("hermes_cli.auth._load_auth_store", return_value={}): result = resolve_provider("auto") diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py index 231d900ec1d4f..d7db358e8988b 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -339,6 +339,28 @@ def test_pricing_cache_peek_zero_priced_model(self, monkeypatch): assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/v1/") is True + def test_nous_welcome_host_is_free_without_pricing(self, monkeypatch): + """Anything the welcome host serves is the free tier, with no pricing lookup: the portal seeds + paid_access=False for a free-tier identity ($0 by design), and that must never raise + credits.depleted ("run /topup") on a surface that cannot top up.""" + from agent.credits_tracker import is_free_tier_model + from hermes_cli import models_pricing + + monkeypatch.setattr(models_pricing, "_pricing_cache", {}) + assert is_free_tier_model("nous/welcome", "https://welcome-api.nousresearch.com/v1") is True + assert is_free_tier_model("some/other", "https://welcome-api.nousresearch.com") is True + + def test_paid_nous_host_still_needs_pricing_evidence(self, monkeypatch): + """The free-tier rule is the host, not the model name: the paid inference host can serve + nous/welcome to a named account, and a depleted named account still sees the notice.""" + from agent.credits_tracker import is_free_tier_model + from hermes_cli import models_pricing + + monkeypatch.setattr(models_pricing, "_pricing_cache", {}) + assert is_free_tier_model("nous/welcome", "https://inference-api.nousresearch.com/v1") is False + assert is_free_tier_model("some/paid", "https://inference-api.nousresearch.com/v1") is False + assert is_free_tier_model("nous/welcome", "") is False + def test_exception_fails_open_to_false(self, monkeypatch): from agent.credits_tracker import is_free_tier_model import hermes_cli.models as models_mod diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 828e83e108a8a..13cb0ab873b05 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -1712,3 +1712,83 @@ def test_other_bad_parameter_via_5xx_stays_non_retryable(self, status_code): assert result.retryable is False + + +# ── Test: Nous welcome tier (free tier) refusals ─────────────────────── + +class TestNousWelcomeTier: + """The Nous gateway's welcome-tier contract: a structured 429 body carries ``reason`` / + ``retry_after`` / ``alternates`` / ``upgrade_url``; a 400/403 names the wrong host or a + dark tier in its message. The parsed refusal rides ``error_context``.""" + + @staticmethod + def _refusal(reason, retry_after=0, **extra): + body = {"status": 429, "message": "refused", "reason": reason, "retry_after": retry_after, **extra} + return MockAPIError(f"Error code: 429 - {body}", status_code=429, body=body, + headers={"retry-after": str(retry_after)}) + + def test_model_not_free_is_a_non_retryable_gate_with_fallback(self): + err = self._refusal("model_not_free", alternates=["nous/welcome"], upgrade_url="https://portal.example/upgrade") + result = classify_api_error(err, provider="nous", model="gpt-5") + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + assert result.should_fallback is True + assert result.should_rotate_credential is False + refusal = result.error_context["welcome_refusal"] + assert refusal["reason"] == "model_not_free" + assert refusal["alternates"] == ["nous/welcome"] + assert refusal["upgrade_url"] == "https://portal.example/upgrade" + + def test_feature_not_free_is_the_same_gate(self): + result = classify_api_error(self._refusal("feature_not_free"), provider="nous") + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + + @pytest.mark.parametrize("reason", ["at_capacity", "admission_closed", "rate_limited"]) + def test_capacity_refusals_are_rate_limits_that_honour_retry_after(self, reason): + result = classify_api_error(self._refusal(reason, retry_after=30), provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + assert result.should_fallback is True + ctx = result.error_context + assert ctx["welcome_refusal"]["retry_after"] == 30 + assert ctx["reset_at"] > 0 + + def test_retry_after_zero_carries_no_reset(self): + result = classify_api_error(self._refusal("at_capacity", retry_after=0), provider="nous") + assert "reset_at" not in result.error_context + + def test_unknown_reason_is_not_the_welcome_shape(self): + err = MockAPIError("Error code: 429", status_code=429, + body={"status": 429, "message": "x", "reason": "something_else", "retry_after": 5}) + result = classify_api_error(err, provider="nous") + assert "welcome_refusal" not in result.error_context + + def test_anonymous_jwt_on_the_paid_host_is_deterministic(self): + body = {"status": 400, "message": "Anonymous accounts must use https://welcome-api.nousresearch.com for inference."} + err = MockAPIError(f"Error code: 400 - {body}", status_code=400, body=body) + result = classify_api_error(err, provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.format_error + assert result.retryable is False and result.should_fallback is True + assert result.error_context["welcome_route"] == "anon_on_paid_host" + + def test_named_caller_on_the_welcome_host_is_deterministic(self): + body = {"status": 400, "message": "This endpoint serves anonymous Hermes Agent accounts only. Use https://inference-api.nousresearch.com with your API key or signed-in account."} + err = MockAPIError(f"Error code: 400 - {body}", status_code=400, body=body) + result = classify_api_error(err, provider="nous") + assert result.error_context["welcome_route"] == "named_on_welcome_host" + assert result.retryable is False + + def test_dark_tier_403_never_triggers_a_credential_refresh(self): + body = {"status": 403, "message": "Anonymous accounts are not accepted by this API right now."} + err = MockAPIError(f"Error code: 403 - {body}", status_code=403, body=body) + result = classify_api_error(err, provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.auth_permanent + assert result.retryable is False and result.should_fallback is True + assert result.should_rotate_credential is False + assert result.error_context["welcome_route"] == "tier_disabled" + + def test_ordinary_403_is_untouched(self): + result = classify_api_error(MockAPIError("forbidden", status_code=403, body={"message": "forbidden"}), provider="nous") + assert result.reason == FailoverReason.auth + assert "welcome_route" not in result.error_context diff --git a/tests/agent/test_nous_welcome_client_contract.py b/tests/agent/test_nous_welcome_client_contract.py new file mode 100644 index 0000000000000..356ea223bd4cc --- /dev/null +++ b/tests/agent/test_nous_welcome_client_contract.py @@ -0,0 +1,163 @@ +"""The client side of the Nous welcome tier's gateway contract: auxiliary calls on the welcome +host use its one model, the ``x-nous-model-switch`` header moves a session off ``nous/welcome``, +and refusal copy names the way forward (never guest / anonymous / claim).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli import anon_auth + +WELCOME = "https://welcome-api.nousresearch.com/v1" +PAID = "https://inference-api.nousresearch.com/v1" + + +# ── Auxiliary client: the welcome host serves exactly one model ────────────────────────────────── + +class TestAuxiliaryOnWelcomeHost: + @pytest.fixture(autouse=True) + def _isolate(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_read_nous_auth", return_value={"auth_method": "anonymous"}), \ + patch.object(ac, "nous_rate_limit_remaining", return_value=None, create=True): + yield + + def test_text_aux_on_welcome_host_pins_the_welcome_model(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", WELCOME)), \ + patch.object(ac, "_create_openai_client", return_value="client") as create, \ + patch("hermes_cli.models.get_nous_recommended_aux_model") as recommended: + client, model = ac._try_nous() + assert (client, model) == ("client", anon_auth.GUEST_MODEL) + assert create.call_args.kwargs["base_url"] == WELCOME + recommended.assert_not_called() # the Portal's pick would be a guaranteed 429 model_not_free + assert ac.auxiliary_is_nous is True + + def test_vision_aux_on_welcome_host_uses_the_same_model(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", WELCOME)), \ + patch.object(ac, "_create_openai_client", return_value="client"), \ + patch("hermes_cli.models.get_nous_recommended_aux_model") as recommended: + assert ac._try_nous(vision=True) == ("client", anon_auth.GUEST_MODEL) + recommended.assert_not_called() + + def test_paid_host_keeps_the_portal_recommendation(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", PAID)), \ + patch.object(ac, "_create_openai_client", return_value="client"), \ + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value="some/free-model"): + client, model = ac._try_nous() + assert (client, model) == ("client", "some/free-model") + + +# ── Model-switch header ─────────────────────────────────────────────────────────────────────────── + +def _agent(model="nous/welcome", base_url=PAID): + return SimpleNamespace(model=model, base_url=base_url, provider="nous", statuses=[]) + + +class TestModelSwitchHeader: + def test_header_is_recorded_not_applied_on_the_streaming_response(self): + agent = _agent() + assert anon_auth.note_model_switch(agent, {"X-Nous-Model-Switch": "z-ai/glm-5.3-flash"}) == "z-ai/glm-5.3-flash" + assert agent.model == "nous/welcome" + assert agent._nous_pending_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") + + def test_no_header_records_nothing(self): + agent = _agent() + assert anon_auth.note_model_switch(agent, {"content-type": "application/json"}) is None + assert getattr(agent, "_nous_pending_model_switch", None) is None + + def test_apply_moves_the_session_and_the_config_default(self, monkeypatch): + agent = _agent() + agent._buffer_status = agent.statuses.append + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + writes = [] + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"model": {"default": "nous/welcome"}}) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", + lambda provider, url, default_model=None, **kw: writes.append((provider, url, default_model))) + assert anon_auth.apply_model_switch(agent) == "z-ai/glm-5.3-flash" + assert agent.model == "z-ai/glm-5.3-flash" + assert agent._nous_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") # what the gateway cache check reads + assert writes == [("nous", PAID, "z-ai/glm-5.3-flash")] + assert agent._nous_pending_model_switch is None + assert agent.statuses and "z-ai/glm-5.3-flash" in agent.statuses[0] + assert anon_auth.apply_model_switch(agent) is None # one application per header + + def test_apply_leaves_a_user_chosen_default_alone(self, monkeypatch): + agent = _agent() + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + writes = [] + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"model": {"default": "openai/gpt-5"}}) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", + lambda *a, **kw: writes.append(a)) + assert anon_auth.apply_model_switch(agent) == "z-ai/glm-5.3-flash" + assert writes == [] + + def test_apply_is_a_noop_when_the_session_already_moved(self): + agent = _agent() + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + agent.model = "openai/gpt-5" # a /model or the sign-in sweep won the race + assert anon_auth.apply_model_switch(agent) is None + assert agent.model == "openai/gpt-5" + + def test_mixin_capture_records_the_header(self): + from agent.rate_limit_credits import RateLimitCreditsMixin + + class _Agent(RateLimitCreditsMixin): + model = "nous/welcome" + provider = "nous" + a = _Agent() + a._capture_nous_model_switch(SimpleNamespace(headers={"x-nous-model-switch": "z-ai/glm-5.3-flash"})) + assert a._nous_pending_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") + a._capture_nous_model_switch(None) # fail-open + + +# ── Refusal copy ────────────────────────────────────────────────────────────────────────────────── + +class TestRefusalCopy: + def test_parse_reads_the_gateway_shape(self): + body = {"status": 429, "message": "m", "reason": "model_not_free", "retry_after": 0, + "alternates": ["nous/welcome"], "upgrade_url": "https://portal.example/upgrade"} + assert anon_auth.parse_welcome_refusal(body) == { + "reason": "model_not_free", "retry_after": 0, "alternates": ["nous/welcome"], + "upgrade_url": "https://portal.example/upgrade"} + assert anon_auth.parse_welcome_refusal({"reason": "nope"}) is None + assert anon_auth.parse_welcome_refusal("not a dict") is None + + def test_copy_names_the_served_model_and_the_sign_in(self): + refusal = anon_auth.parse_welcome_refusal({"reason": "model_not_free", "alternates": ["nous/welcome"]}) + chat = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=True) + assert chat == "gpt-5 isn't on the Nous free tier; it serves nous/welcome only. Sign in with a Nous account for the full catalog: /login." + terminal = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=False) + assert "`hermes auth upgrade`" in terminal and "/login" not in terminal + + def test_capacity_copy_carries_the_retry(self): + refusal = anon_auth.parse_welcome_refusal({"reason": "at_capacity", "retry_after": 30}) + assert "Retrying in 30s." in anon_auth.welcome_refusal_copy(refusal) + + @pytest.mark.parametrize("copy_fn, args", [ + (anon_auth.welcome_refusal_copy, ({"reason": r},)) for r in sorted(anon_auth.WELCOME_REFUSAL_REASONS) + ] + [(anon_auth.welcome_route_refusal_copy, (k,)) for k in ("anon_on_paid_host", "named_on_welcome_host", "tier_disabled")]) + def test_copy_never_says_guest_anonymous_or_claim(self, copy_fn, args): + text = copy_fn(*args).lower() + assert not any(word in text for word in ("guest", "anonymous", "claim")) + + def test_route_refusal_detection(self): + assert anon_auth.welcome_route_refusal(400, "Anonymous accounts must use https://x for inference.") == "anon_on_paid_host" + assert anon_auth.welcome_route_refusal(403, "Anonymous accounts are not accepted by this API right now.") == "tier_disabled" + assert anon_auth.welcome_route_refusal(429, "Anonymous accounts must use") is None + assert anon_auth.welcome_route_refusal(400, "bad request") is None + + +class TestTurnRecoveryGuidance: + def test_guidance_from_classified_context(self): + from agent.turn_recovery import _welcome_tier_guidance + classified = SimpleNamespace(error_context={"welcome_refusal": {"reason": "model_not_free", "retry_after": 0, "alternates": []}}) + assert "nous/welcome" in _welcome_tier_guidance(classified, model="gpt-5", in_chat=True) + classified = SimpleNamespace(error_context={"welcome_route": "tier_disabled"}) + assert "switched off" in _welcome_tier_guidance(classified, model="", in_chat=True) + assert _welcome_tier_guidance(SimpleNamespace(error_context={}), model="", in_chat=True) == "" diff --git a/tests/gateway/test_free_tier_gateway_boot.py b/tests/gateway/test_free_tier_gateway_boot.py new file mode 100644 index 0000000000000..793197f56b1ae --- /dev/null +++ b/tests/gateway/test_free_tier_gateway_boot.py @@ -0,0 +1,52 @@ +"""The messaging gateway is a boot owner of the Nous free tier. + +Rung 5 made every demand-time site a read (provider resolution, ``/login``, the connector token), so a +process that never runs the bootstrap can never have an identity. `cmd_chat` and `hermes serve` run it; +this file pins that `hermes gateway run` does too, and does it BEFORE any adapter connects, so a fast +first DM cannot arrive with nothing to resolve. +""" + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.run import GatewayRunner +import gateway.run_startup as run_startup + + +@pytest.mark.asyncio +async def test_gateway_boot_runs_the_free_tier_bootstrap_before_any_adapter_connects(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + order: list[str] = [] + + def fake_bootstrap() -> None: + order.append("bootstrap") + + async def fake_prefilter(self): + order.append("prefilter-platforms") + # No pending connects: startup exits cleanly at the no-connections check, which is exactly the + # path the cold cell drives. Shape mirrors the real return. + return (False, 0, [], []) + + monkeypatch.setattr(GatewayRunner, "_start_free_tier_bootstrap", staticmethod(fake_bootstrap)) + monkeypatch.setattr(run_startup.GatewayStartupMixin, "_start_prefilter_platforms", fake_prefilter) + + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=False)}, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + ok = await runner.start() + + assert ok is True + assert order[:2] == ["bootstrap", "prefilter-platforms"], order + + +def test_gateway_bootstrap_seam_calls_the_one_creator(monkeypatch): + """The seam delegates to `free_tier_bootstrap.run_bootstrap`; nothing else in the gateway may mint.""" + import hermes_cli.free_tier_bootstrap as ftb + calls: list[dict] = [] + monkeypatch.setattr(ftb, "run_bootstrap", lambda **kw: calls.append(kw)) + + GatewayRunner._start_free_tier_bootstrap() + + assert calls == [{"announce": False}] diff --git a/tests/gateway/test_free_tier_startup_notice.py b/tests/gateway/test_free_tier_startup_notice.py index 2f50710a3a882..0d703d9fefa1d 100644 --- a/tests/gateway/test_free_tier_startup_notice.py +++ b/tests/gateway/test_free_tier_startup_notice.py @@ -48,7 +48,7 @@ def _account_state() -> dict: 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") # 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) diff --git a/tests/gateway/test_login_command.py b/tests/gateway/test_login_command.py index da2b0b825f6b9..4fb522c074cc7 100644 --- a/tests/gateway/test_login_command.py +++ b/tests/gateway/test_login_command.py @@ -188,7 +188,7 @@ async def deliver(_source, text): assert delivered[-2:] == [ "Do not share this code. Waiting for sign-in, up to 1 minute.", - "Signed in as person@example.test. Your connectors are kept.\nDefault model is now model-1.", + "Signed in as person@example.test.\nDefault model is now model-1.", ] assert "push failed" in caplog.text @@ -447,7 +447,7 @@ async def test_the_sweep_is_skipped_when_the_model_did_not_change(monkeypatch): runner._evict_cached_agent.assert_not_called() runner.async_session_store.set_model_override.assert_not_awaited() assert runner._deliver_platform_notice.await_args_list[-1].args[1] == ( - "Signed in as person@example.test. Your connectors are kept.") + "Signed in as person@example.test.") @pytest.mark.asyncio @@ -460,7 +460,7 @@ async def test_a_completion_with_no_default_names_the_slash_command(monkeypatch) ) assert runner._deliver_platform_notice.await_args_list[-1].args[1] == ( - "Signed in as person@example.test. Your connectors are kept.\n" + "Signed in as person@example.test.\n" "No default model is set yet; run /model to pick one.") diff --git a/tests/gateway/test_model_switch_persistence.py b/tests/gateway/test_model_switch_persistence.py index ea0a175c1a72d..a668518a3efe9 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -127,7 +127,9 @@ def test_no_override_returns_originals(self): class TestIsIntentionalModelSwitch: - """Verify fallback detection respects intentional /model overrides.""" + """The fallback-eviction check must not evict a session whose model differs from the config + default for a reason the system produced: a /model override, or the Nous gateway moving the + session off the ``nous/welcome`` alias the config still carries.""" def test_matches_override(self): runner = _make_runner() @@ -141,7 +143,26 @@ def test_matches_override(self): "api_mode": "chat_completions", } - assert runner._is_intentional_model_switch(sk, "gpt-5.4") is True + agent = SimpleNamespace(model="gpt-5.4") + assert runner._is_intentional_model_switch(sk, agent, "openai/gpt-5") is True + + def test_server_model_switch_off_the_welcome_alias_is_intentional(self): + runner = _make_runner() + sk = build_session_key(_make_source()) + # apply_model_switch moved the session and recorded the move (alias -> backing). + agent = SimpleNamespace(model="z-ai/glm-5.3-flash", _nous_model_switch=("nous/welcome", "z-ai/glm-5.3-flash")) + assert runner._is_intentional_model_switch(sk, agent, "nous/welcome") is True + # A config that names something else is real drift, not the server's move. + assert runner._is_intentional_model_switch(sk, agent, "openai/gpt-5") is False + # A later fallback onto a third model is drift too, even with the config still on the alias. + agent.model = "fallback/model" + assert runner._is_intentional_model_switch(sk, agent, "nous/welcome") is False + + def test_plain_drift_is_not_intentional(self): + runner = _make_runner() + sk = build_session_key(_make_source()) + agent = SimpleNamespace(model="fallback/model") + assert runner._is_intentional_model_switch(sk, agent, "primary/model") is False class TestOneTurnModelOverrideRestore: diff --git a/tests/gateway/test_status_free_tier_line.py b/tests/gateway/test_status_free_tier_line.py index 4202668362966..f979e2613e638 100644 --- a/tests/gateway/test_status_free_tier_line.py +++ b/tests/gateway/test_status_free_tier_line.py @@ -74,7 +74,7 @@ def _account_state() -> dict: @pytest.fixture(autouse=True) def isolated_auth_store(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") @pytest.mark.asyncio diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index 144a44869c86a..390c9a36a695b 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -39,6 +39,8 @@ def __init__(self): self.dead_tokens: set[str] = set() self.gate_closed = False self.minted = 0 + # What the token exchange names as the inference host; None = an older NAS that omits it. + self.inference_base_url: str | None = WELCOME def handler(self, request: httpx.Request) -> httpx.Response: path = request.url.path @@ -55,9 +57,11 @@ def handler(self, request: httpx.Request) -> httpx.Response: 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}) + body = {"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1"} + if self.inference_base_url: + body["inference_base_url"] = self.inference_base_url + return httpx.Response(200, json=body) return httpx.Response(500, json={"error": f"unexpected {path}"}) @@ -67,7 +71,7 @@ def portal(monkeypatch, tmp_path): 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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 @@ -84,9 +88,9 @@ def __init__(self, *a, **kw): 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 + from hermes_cli import free_tier_bootstrap as _fb + _fb.reset_for_tests() # 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 @@ -110,7 +114,7 @@ def _shared_store(tmp_path) -> dict: 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) + state = anon_auth.ensure_portal_identity(explicit=True) assert anon_auth.is_guest_state(state) assert "refresh_token" not in state store = _load_auth_store() @@ -120,16 +124,16 @@ def test_fresh_install_mints_once_and_is_the_active_provider(self, portal, tmp_p 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 anon_auth.ensure_portal_identity(explicit=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) + first = anon_auth.ensure_portal_identity(explicit=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) + second = anon_auth.ensure_portal_identity(explicit=True) assert second["anon_token"] == first["anon_token"] assert len(portal.calls) == before, "adoption must not touch the network" assert portal.minted == 1 @@ -137,40 +141,85 @@ def test_second_profile_under_same_root_adopts_from_shared_store(self, portal, t 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) + anon_auth.ensure_portal_identity(explicit=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 anon_auth.ensure_portal_identity(explicit=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 + # A developer machine's ~/.aws would answer the Bedrock rung and hide the AuthError. + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + assert anon_auth.ensure_portal_identity(explicit=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"] + def test_launch_gate_off_means_no_free_tier_at_all(self, portal, monkeypatch): + """Without ``HERMES_GUEST_ONBOARDING=1`` the free tier does not exist: no mint, no portal + traffic, ``nous.guest``'s default is never consulted, and an identity already on disk is + not treated as enabled. The env var is the only lever; ``0``/``true``/anything but ``1`` is off.""" + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + for raw in ("", "0", "true", "yes", "new"): + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", raw) + assert anon_auth.guest_enabled() is False + assert anon_auth.ensure_portal_identity(explicit=True) is None + assert portal.calls == [] + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") + assert anon_auth.guest_enabled() is True + + +class TestExplicitProvision: + """``ensure_portal_identity(explicit=True)`` is the one creator (the boot bootstrap and the desktop + retry call it). It mints once; every later caller adopts that identity through the shared store, + so two boots never create two identities. ``nous.guest: false`` still wins.""" + + def test_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, tmp_path): + assert anon_auth.is_guest_state(anon_auth.ensure_portal_identity(explicit=True)) + assert portal.minted == 1 + token = _load_auth_store()["providers"]["nous"]["anon_token"] + # A second provision is idempotent, and the runtime now serves nous/welcome on the welcome host. + anon_auth.ensure_portal_identity(explicit=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + assert runtime["base_url"].rstrip("/") == WELCOME + assert resolve_provider("auto") == "nous" + # A sibling profile adopts the same identity implicitly; no second create call. + sibling = tmp_path / "sibling-profile" + sibling.mkdir() + monkeypatch.setenv("HERMES_HOME", str(sibling)) + adopted = anon_auth.ensure_portal_identity(explicit=True) + assert adopted and adopted["anon_token"] == token + assert portal.minted == 1 + + def test_retired_identity_is_replaced(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(explicit=True) + first = _load_auth_store()["providers"]["nous"]["anon_token"] + portal.dead_tokens.add(first) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + assert resolve_nous_runtime_credentials(force_refresh=True)["api_key"] # replaced, not refused + assert _load_auth_store()["providers"]["nous"]["anon_token"] != first assert portal.minted == 2 + def test_guest_off_beats_an_explicit_provision(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False) + assert anon_auth.ensure_portal_identity(explicit=True) is None + assert portal.minted == 0 + class TestResolverIsUnchanged: def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() assert runtime["provider"] == "nous" @@ -178,9 +227,42 @@ def test_runtime_routes_to_welcome_host(self, portal): assert runtime["api_key"] +class TestRouteFallback: + """A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there.""" + + def test_exchange_without_inference_url_routes_to_welcome_literal(self, portal): + portal.inference_base_url = None + anon_auth.ensure_portal_identity(explicit=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["base_url"].rstrip("/") == WELCOME + state = _load_auth_store()["providers"]["nous"] + assert state["inference_base_url"].rstrip("/") == WELCOME + + def test_disallowed_inference_host_heals_to_welcome_literal(self, portal): + portal.inference_base_url = "https://welcome-api.staging-nousresearch.com/v1" + anon_auth.ensure_portal_identity(explicit=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["base_url"].rstrip("/") == WELCOME + + def test_guest_state_without_url_never_resolves_to_the_paid_host(self, portal): + from hermes_cli.auth_nous import _nous_effective_routing + guest = {"auth_method": "anonymous", "anon_token": "anon_x"} + _portal, stored, effective, _client = _nous_effective_routing(guest) + assert stored.rstrip("/") == WELCOME and effective.rstrip("/") == WELCOME + _portal, stored, _effective, _client = _nous_effective_routing({"refresh_token": "r"}) + assert stored.rstrip("/") == "https://inference-api.nousresearch.com/v1" + + def test_shared_store_shape_keeps_a_guest_on_the_welcome_host(self, portal): + from hermes_cli.auth_nous import _nous_shared_shape + shape = _nous_shared_shape({"auth_method": "anonymous", "anon_token": "anon_x"}) + assert shape["inference_base_url"].rstrip("/") == WELCOME + + class TestTokenAcquisitionSeam: def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store with _auth_store_lock(): store = _load_auth_store() @@ -197,7 +279,7 @@ def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): 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) + first = anon_auth.ensure_portal_identity(explicit=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) @@ -207,7 +289,7 @@ def test_dead_credential_is_replaced_by_a_fresh_identity(self, portal): assert portal.minted == 2 def test_tool_gateway_token_path_reexchanges(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store, resolve_nous_access_token with _auth_store_lock(): store = _load_auth_store() @@ -224,13 +306,13 @@ class TestModelPin: 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 + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=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) @@ -244,7 +326,7 @@ 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) + anon_auth.ensure_portal_identity(explicit=True) before = _auth_file_path().read_bytes() logout_command(SimpleNamespace(provider=None)) out = capsys.readouterr().out.lower() @@ -267,7 +349,7 @@ def test_logout_of_real_account_clears_shared_store(self, portal, tmp_path): 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) + anon_auth.ensure_portal_identity(explicit=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) @@ -306,51 +388,78 @@ def test_welcome_conversation_may_move_to_the_portal_host(self, portal): 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 +class TestBootstrapIsTheOneCreator: + """``free_tier_bootstrap.run_bootstrap`` is the only place an identity is created. Every other + site is a read. One mint per process; the record says who carries inference.""" + + def _fresh(self): + from hermes_cli import free_tier_bootstrap as fb + fb.reset_for_tests() + return fb + + def test_bootstrap_mints_once_records_and_a_second_run_is_free(self, portal): + fb = self._fresh() + record = fb.run_bootstrap() + assert record.free_tier and record.has_identity and record.provider_configured + assert record.inference_provider == "nous" and record.other_providers is False + assert portal.minted == 1 + again = fb.run_bootstrap() + assert again is record and portal.minted == 1, "a second boot in the same process adopts, never mints" + assert fb.wait_for_record(timeout=0) is record + + def test_own_key_keeps_inference_and_the_identity_stays_off_active_provider(self, portal, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-own-key") + fb = self._fresh() + record = fb.run_bootstrap() + assert record.other_providers is True and record.has_identity is True + assert record.free_tier is True, "the identity exists for connectors" + assert record.inference_provider != "nous" + assert _load_auth_store().get("active_provider") != "nous", "a mint beside an own key must not hijack inference" + assert portal.minted == 1 + + def test_reads_never_mint(self, portal, monkeypatch): + """status, provider resolution and the connector bearer are reads: with no identity they + answer 'nothing' and touch no network.""" + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + from tools.managed_tool_gateway import read_nous_access_token + assert read_nous_access_token() is None + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + from hermes_cli.main import _has_any_provider_configured + _has_any_provider_configured() + assert not anon_auth.has_guest() + assert portal.calls == [], "no read path may reach the portal" + with pytest.raises(ValueError): + anon_auth.ensure_portal_identity(explicit=False) + + def test_bootstrap_with_the_gate_closed_records_the_refusal_and_stops(self, portal, monkeypatch): + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) 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 + fb = self._fresh() + record = fb.run_bootstrap() + assert record.has_identity is False and record.free_tier is False and record.error + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 + # The explicit retry (desktop free_tier.provision) is also memoised for the process. + assert anon_auth.ensure_portal_identity(explicit=True) is None + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 class TestIdentityOfRecordIsTheSharedStore: def test_stale_profile_guest_adopts_a_newer_shared_account(self, portal, tmp_path): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=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) + state = anon_auth.ensure_portal_identity(explicit=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) + first = anon_auth.ensure_portal_identity(explicit=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 @@ -360,7 +469,7 @@ def test_mint_persists_before_any_exchange_and_first_use_exchanges_once(self, po 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) + anon_auth.ensure_portal_identity(explicit=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"}) @@ -387,13 +496,13 @@ def 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) + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=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 @@ -403,7 +512,7 @@ def test_opt_out_hides_the_free_tier_from_connectors_including_cached_tokens(sel 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) + first = anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store with _auth_store_lock(): store = _load_auth_store() diff --git a/tests/hermes_cli/test_anon_desktop_signin.py b/tests/hermes_cli/test_anon_desktop_signin.py index 819817a57ecbd..f1849a7fcea90 100644 --- a/tests/hermes_cli/test_anon_desktop_signin.py +++ b/tests/hermes_cli/test_anon_desktop_signin.py @@ -37,7 +37,7 @@ def _wait_for_terminal(session_id: str, timeout: float = 10.0) -> dict: def test_start_registers_the_transfer_and_completion_settles_the_account(portal, free_account): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) resp = client.post("/api/providers/oauth/nous/start", headers=HEADERS) @@ -62,7 +62,7 @@ def test_start_registers_the_transfer_and_completion_settles_the_account(portal, def test_a_transfer_the_user_declined_is_reported_with_its_reason_and_keeps_the_free_tier(portal, free_account): - guest = anon_auth.ensure_portal_identity(blocking=True) + guest = anon_auth.ensure_portal_identity(explicit=True) portal.status_sequence = [{"status": "voided", "reason": "user_declined"}] start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json() @@ -75,7 +75,7 @@ def test_a_transfer_the_user_declined_is_reported_with_its_reason_and_keeps_the_ def test_status_routes_report_the_free_tier(portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) portal_status = client.get("/api/portal", headers=HEADERS).json() assert portal_status["free_tier"] is True assert portal_status["account_tier"] == "anonymous" @@ -89,7 +89,7 @@ def test_a_sign_in_cancelled_while_waiting_never_persists_the_account(portal, fr nothing may reach the auth store.""" import threading from hermes_cli import web_server_oauth - guest = anon_auth.ensure_portal_identity(blocking=True) + guest = anon_auth.ensure_portal_identity(explicit=True) release = threading.Event() def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None): diff --git a/tests/hermes_cli/test_anon_first_notice.py b/tests/hermes_cli/test_anon_first_notice.py index b147952fa7f89..6d0c956cd5f64 100644 --- a/tests/hermes_cli/test_anon_first_notice.py +++ b/tests/hermes_cli/test_anon_first_notice.py @@ -51,7 +51,7 @@ def _console_print(self, *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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") def test_notice_prints_once_after_identity_appears_and_persists_the_flag(): diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py index ee7f0803f89ac..4004f9b3b16bd 100644 --- a/tests/hermes_cli/test_anon_picker.py +++ b/tests/hermes_cli/test_anon_picker.py @@ -29,7 +29,7 @@ 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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)}}) diff --git a/tests/hermes_cli/test_anon_sign_in_flow.py b/tests/hermes_cli/test_anon_sign_in_flow.py index fa27ee3fbe014..5cc28a621152d 100644 --- a/tests/hermes_cli/test_anon_sign_in_flow.py +++ b/tests/hermes_cli/test_anon_sign_in_flow.py @@ -28,7 +28,7 @@ def _drain(**kwargs): def _seed_free_tier() -> dict: - return anon_auth.ensure_portal_identity(blocking=True) + return anon_auth.ensure_portal_identity(explicit=True) def _stub_wait(monkeypatch, outcome, *, before=None): @@ -249,42 +249,20 @@ def test_free_tier_off_yields_unavailable(portal, monkeypatch): assert portal.calls == [] -def test_an_auth_error_while_provisioning_names_the_cause_in_the_terminal_form_only(portal, monkeypatch): - def _closed(**kwargs): - raise AuthError("gate closed") - monkeypatch.setattr(anon_auth, "ensure_portal_identity", _closed) - - state = _drain()[-1] - - assert state.kind == "unavailable" - assert state.copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (gate closed)" - assert state.copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT - - -def test_a_transport_failure_while_provisioning_yields_unavailable_rather_than_raising(portal, monkeypatch): - def _offline(**kwargs): - raise httpx.ConnectError("connection refused") - monkeypatch.setattr(anon_auth, "ensure_portal_identity", _offline) +def test_no_identity_on_disk_yields_unavailable_without_touching_the_portal(portal, monkeypatch): + """A sign-in never creates the identity it signs in from: the boot bootstrap is the only creator. + With nothing on disk the flow yields ``Unavailable`` and makes no portal call and no mint attempt.""" + monkeypatch.setattr(anon_auth, "ensure_portal_identity", + lambda **kwargs: (_ for _ in ()).throw(AssertionError("run_sign_in must not mint"))) + monkeypatch.setattr(anon_auth, "mint_guest", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("run_sign_in must not mint"))) states = _drain() assert [s.kind for s in states] == ["unavailable"] - assert states[-1].copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (connection refused)" - assert states[-1].copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT - - -def test_a_transport_failure_while_minting_yields_unavailable(portal, monkeypatch): - # ``mint_guest`` is called positionally from inside the store lock, so the stub takes *args: - # the failure under test must be the transport error, not a signature mismatch. - def _offline(*args, **kwargs): - raise httpx.ConnectError("connection refused") - monkeypatch.setattr(anon_auth, "mint_guest", _offline) - - states = _drain() - - assert [s.kind for s in states] == ["unavailable"] - assert states[-1].copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (connection refused)" + assert states[-1].copy_terminal == anon_auth.UPGRADE_UNAVAILABLE assert states[-1].copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT + assert portal.calls == [] def test_cancelling_before_the_wait_persists_nothing(portal, tmp_path): diff --git a/tests/hermes_cli/test_anon_surfaces.py b/tests/hermes_cli/test_anon_surfaces.py index 165b63945b2f5..19cf5d140f9ca 100644 --- a/tests/hermes_cli/test_anon_surfaces.py +++ b/tests/hermes_cli/test_anon_surfaces.py @@ -72,7 +72,7 @@ def _account_state() -> dict: @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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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. diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py index 1b1fc5c120da9..15ffa1df59bf7 100644 --- a/tests/hermes_cli/test_anon_upgrade.py +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -99,7 +99,7 @@ def portal(monkeypatch, tmp_path): 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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 @@ -115,9 +115,7 @@ def __init__(self, *a, **kw): 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 @@ -132,7 +130,7 @@ def _args(): class TestUpgrade: def test_intent_carries_both_device_codes_from_the_code_request(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) anon_auth.upgrade_guest(_args()) assert len(portal.intent_bodies) == 1 body = portal.intent_bodies[0] @@ -144,7 +142,7 @@ def test_intent_carries_both_device_codes_from_the_code_request(self, portal): 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) + anon_auth.ensure_portal_identity(explicit=True) before = _auth_file_path().read_bytes() shared_before = _shared_store(tmp_path) portal.status_sequence = [{"status": "pending"}, {"status": "voided", "reason": "user_declined"}] @@ -157,12 +155,12 @@ def test_declined_in_browser_prints_copy_and_leaves_auth_store_untouched(self, p 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) + guest = anon_auth.ensure_portal_identity(explicit=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 + assert f"Signed in as {EMAIL}." 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}" @@ -209,7 +207,7 @@ def _model_config() -> dict: class TestSignInCompletionSettlesTheModel: def test_config_on_the_free_tier_route_moves_to_the_account_host_and_the_recommended_free_model( self, portal, free_account, capsys): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) # What picking the free-tier row leaves behind: the welcome model pinned to the welcome host. _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) assert anon_auth.upgrade_guest(_args()) == 0 @@ -220,7 +218,7 @@ def test_config_on_the_free_tier_route_moves_to_the_account_host_and_the_recomme assert f"Default model is now {FREE_PICK}." in capsys.readouterr().out def test_config_on_the_users_own_model_is_left_alone(self, portal, free_account, capsys): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) own = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4"} _write_model_config(own) assert anon_auth.upgrade_guest(_args()) == 0 @@ -230,7 +228,7 @@ def test_config_on_the_users_own_model_is_left_alone(self, portal, free_account, def test_no_eligible_recommendation_leaves_no_default_rather_than_a_model_the_account_may_not_use( self, portal, free_account, monkeypatch, capsys): from hermes_cli import models as m - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) def _portal_down(): raise RuntimeError("recommended models unavailable") diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py index 9127935dd6d0f..4ac834166c603 100644 --- a/tests/hermes_cli/test_banner.py +++ b/tests/hermes_cli/test_banner.py @@ -84,3 +84,28 @@ def test_build_welcome_banner_non_moa_unchanged(tmp_path, monkeypatch): out = console.export_text() assert "claude-opus-4.8" in out assert "MoA:" not in out + + +def test_empty_model_shows_the_free_tier_route_when_it_carries_inference(tmp_path, monkeypatch): + """The banner prints before credentials resolve, so ``model`` is empty on a fresh install. On the + free tier the route is known locally (identity on disk + tier on): the banner shows its model. + When nothing resolves the red "no model configured" line stays.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + import hermes_cli.anon_auth as anon_auth + + def render(carries: bool) -> str: + with ( + patch.object(model_tools, "check_tool_availability", return_value=([], [])), + patch.object(banner, "get_available_skills", return_value={}), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool_discovery, "get_mcp_status", return_value=[]), + patch.object(anon_auth, "guest_carries_inference", return_value=carries), + ): + console = Console(record=True, force_terminal=False, color_system=None, width=160) + banner.build_welcome_banner(console=console, model="", cwd="/tmp/project", tools=[], + enabled_toolsets=[], provider="auto") + return console.export_text() + + assert "welcome" in render(True) and "no model configured" not in render(True) + assert "no model configured" in render(False) diff --git a/tests/hermes_cli/test_login_cli_command.py b/tests/hermes_cli/test_login_cli_command.py index d914051728e14..1af19eca7e2b0 100644 --- a/tests/hermes_cli/test_login_cli_command.py +++ b/tests/hermes_cli/test_login_cli_command.py @@ -59,7 +59,7 @@ def test_the_cli_handler_prints_the_code_then_drains_off_thread(monkeypatch): ] assert workers[0][0].started is True assert workers[0][0].target() == ( - "Signed in as person@example.test. Your connectors are kept.\nDefault model is now model-1.") + "Signed in as person@example.test.\nDefault model is now model-1.") @pytest.mark.parametrize( @@ -178,7 +178,7 @@ def flow(**_kwargs): gate.set() threads[0].join(timeout=2) - assert "Signed in as person@example.test. Your connectors are kept." in old_buf.getvalue() + assert "Signed in as person@example.test." in old_buf.getvalue() assert new_buf.getvalue() == "" @@ -225,7 +225,7 @@ def flow(**_kwargs): assert old_buf.getvalue() == "" assert new_buf.getvalue() == "" assert " Sign-in" in output - assert any("Signed in as person@example.test. Your connectors are kept." in line for line in output) + assert any("Signed in as person@example.test." in line for line in output) def test_upgrade_guest_keeps_the_terminal_timeout(monkeypatch): @@ -255,6 +255,6 @@ def test_the_terminal_renderer_keeps_the_original_line_sequence(monkeypatch, cap " 2. If prompted, enter code: CODE-1", f" {anon_auth.UPGRADE_DO_NOT_SHARE}", anon_auth.UPGRADE_WAITING, - "Signed in as person@example.test. Your connectors are kept.", + "Signed in as person@example.test.", "Default model is now model-1.", ] diff --git a/tests/hermes_cli/test_provider_precedence.py b/tests/hermes_cli/test_provider_precedence.py index 16a59be469eeb..4f3f13d4c6d3b 100644 --- a/tests/hermes_cli/test_provider_precedence.py +++ b/tests/hermes_cli/test_provider_precedence.py @@ -90,3 +90,58 @@ def has_credentials(self): monkeypatch.setattr("agent.credential_pool.load_pool", lambda name: _Pool()) assert resolve_provider("auto") == "openrouter" + + +def _logged_out(monkeypatch): + monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: {}) + monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda p: {"logged_in": False}) + + +def _free_tier(monkeypatch, *, on=True, identity=False): + """Free tier switch + whether a free-tier identity already exists. The resolver is a READ: any + call into the creator from inside it is a bug, so the stub fails loudly.""" + monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: on) + monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: identity) + monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", + lambda **kw: (_ for _ in ()).throw(AssertionError("resolve_provider must not mint"))) + + +class TestFreeTierBeatsImplicitHostCredentials: + """NS-829: a leftover ~/.aws profile must not pre-empt the free tier on a fresh install. + + The ladder: explicit intent still wins, an EXISTING free-tier identity sits above the implicit + Bedrock chain, the free tier off (or its identity absent) restores Bedrock. The resolver never + creates the identity; the boot bootstrap does, before any turn asks.""" + + @pytest.mark.parametrize("free_tier_on, identity, env_key, login, expected", [ + (True, True, None, None, "nous"), # existing identity beats the AWS chain + (True, False, None, None, "bedrock"), # no identity yet: Bedrock, nothing minted + (False, True, None, None, "bedrock"), # free tier off: Bedrock as before + (True, True, "OPENAI_API_KEY", None, "openrouter"), # env key still wins + (True, True, None, "anthropic", "anthropic"), # a sign-in still wins + ]) + def test_free_tier_sits_above_the_bedrock_chain(self, monkeypatch, free_tier_on, identity, + env_key, login, expected): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + if login: + _login(monkeypatch, login) + else: + _logged_out(monkeypatch) + if env_key: + monkeypatch.setenv(env_key, "sk-test-key") + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) + _free_tier(monkeypatch, on=free_tier_on, identity=identity) + assert resolve_provider("auto") == expected + + def test_skip_free_tier_answers_what_else_would_carry_inference(self, monkeypatch): + """The bootstrap's question: with the free tier hidden, an existing identity is not an + answer and the ladder falls through to the next real rung.""" + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + _free_tier(monkeypatch, on=True, identity=True) + assert resolve_provider("auto") == "nous" + with pytest.raises(AuthError): + resolve_provider("auto", skip_free_tier=True) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 69cdc03432899..443f3e5ce8ac0 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -8974,6 +8974,36 @@ def test_setup_status_reports_provider_config(monkeypatch): assert resp["result"]["provider_configured"] is False +def test_setup_status_answers_from_the_bootstrap_record_once_it_exists(monkeypatch): + """Under ``hermes serve`` the boot bootstrap owns the free-tier identity; ``setup.status`` reports + its record (blocking for it while it is in flight) instead of re-probing, so a client's first poll + sees the identity that exists rather than racing the mint.""" + import threading + from hermes_cli import free_tier_bootstrap as fb + fb.reset_for_tests() + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", + lambda **_kw: pytest.fail("setup.status must read the record, not re-probe")) + release = threading.Event() + + def slow_bootstrap(): + release.wait(5) + with fb._lock: + fb._record = fb.SetupRecord(provider_configured=True, inference_provider="nous", free_tier=True, + has_identity=True, other_providers=False) + fb._done.set() + with fb._lock: + fb._started = True + threading.Thread(target=slow_bootstrap, daemon=True).start() + try: + release.set() + resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}}) + assert resp["result"]["provider_configured"] is True + assert resp["result"]["ready"] is True and resp["result"]["free_tier"] is True + assert resp["result"]["inference_provider"] == "nous" + finally: + fb.reset_for_tests() + + def test_probe_credentials_emits_exact_empty_key_warning(): agent = types.SimpleNamespace(api_key="", provider="openrouter") diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py index 116e9002683ee..bbe94cfbb5c12 100644 --- a/tests/tui_gateway/test_free_tier_rpc.py +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -29,7 +29,7 @@ def _call(method: str, params: dict | None = None) -> dict: @pytest.fixture def guest(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") with _auth_store_lock(): store = _load_auth_store() store.setdefault("providers", {})["nous"] = { @@ -76,10 +76,48 @@ def test_billing_state_answers_the_free_tier_locally(guest, monkeypatch): assert res["free_tier"] is False and res["free_tier_model"] is None -def test_status_without_an_identity_starts_the_background_setup_once(tmp_path, monkeypatch): +def test_status_without_an_identity_is_a_pure_read(tmp_path, monkeypatch): + """The desktop polls ``free_tier.status`` every status round; a poll must never create the identity + (that is the boot bootstrap's job).""" monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - calls = [] - monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: calls.append(kw) or None) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") + monkeypatch.setattr(anon_auth, "ensure_portal_identity", + lambda **kw: (_ for _ in ()).throw(AssertionError("free_tier.status must not mint"))) status = _call("free_tier.status") - assert status["has_guest"] is False and status["available"] is False - assert calls == [{"blocking": False}] + assert status["has_guest"] is False and status["available"] is False and status["enabled"] is True + + +def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_path, monkeypatch): + """``free_tier.provision`` is the desktop's explicit retry: it calls the one creator + (``ensure_portal_identity(explicit=True)``) only when no identity exists, and reports the outcome.""" + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") + calls = [] + + def fake_provision(**kw): + calls.append(kw) + 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_0002"} + _save_auth_store(store) + return store["providers"]["nous"] + + monkeypatch.setattr(anon_auth, "ensure_portal_identity", fake_provision) + assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} + assert calls == [{"explicit": True}] + assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} + assert len(calls) == 1 # idempotent: an identity exists, nothing is minted + + def refused(**kw): + raise anon_auth.AuthError("Nous free tier is not open on this portal.", code="anon_gate_closed") + + with _auth_store_lock(): + store = _load_auth_store(); store["providers"].pop("nous"); _save_auth_store(store) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", refused) + result = _call("free_tier.provision") + assert result["has_guest"] is False and "not open" in result["error"] + + _set_guest_off(monkeypatch) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: (_ for _ in ()).throw(AssertionError("must not run"))) + assert _call("free_tier.provision") == {"has_guest": False, "enabled": False} diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 458c4daf31775..5c303c57aec8d 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -98,23 +98,16 @@ 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. - 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. + A read: with no Nous identity there is no bearer and the answer is None. The free-tier identity + is created by the boot bootstrap (``hermes_cli.free_tier_bootstrap``), never on a token-read + path (NS-845 Q1.2). A retired free-tier credential IS replaced here, once: that is the explicit + dead-credential rule, shared with inference. """ 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 + 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 @@ -140,7 +133,7 @@ def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) try: - if ensure_portal_identity(blocking=True) is None: + if ensure_portal_identity(explicit=True) is None: return None return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) except Exception as exc: diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 9458478448c3b..424fb48210395 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -266,12 +266,26 @@ def _readiness_check(rid, params, probe): @method("setup.status") def _(rid, params: dict) -> dict: - """Loose provider check; ``profile`` (optional) scopes it to that profile's home.""" + """Loose provider check; ``profile`` (optional) scopes it to that profile's home. + + For the launch profile the answer is the boot bootstrap's record (``free_tier_bootstrap``): + the call blocks up to ``SETUP_READY_WAIT_SECONDS`` for it, so a client's first poll lands after + the free-tier identity exists (or has been refused) rather than racing the mint. If the record + is still missing after the wait, or a named profile is asked about, today's live probe answers. + The record's fields ride along additively (``ready``, ``free_tier``, ``other_providers``).""" try: from hermes_cli.main import _has_any_provider_configured - return _readiness_check(rid, params, lambda profile, scoped: { - "provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))), - **scoped}) + from hermes_cli.free_tier_bootstrap import wait_for_record + + def probe(profile, scoped): + record = None if profile else wait_for_record() + if record is None: + return {"provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))), + **scoped} + return {"provider_configured": record.provider_configured, "ready": True, + "free_tier": record.free_tier, "other_providers": record.other_providers, + "inference_provider": record.inference_provider, **scoped} + return _readiness_check(rid, params, probe) except Exception as e: return _err(rid, 5016, str(e)) diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py index 3cf66efb9c58a..c71590ce5de73 100644 --- a/tui_gateway/methods_free_tier.py +++ b/tui_gateway/methods_free_tier.py @@ -1,5 +1,7 @@ """Nous free-tier JSON-RPC handlers: a renderer reads the profile's local auth state (pull); nothing -is pushed. ``free_tier.status`` answers from the auth store with zero network; ``free_tier.ack_notice`` +is pushed except the boot bootstrap's one ``setup.ready`` event. ``free_tier.status`` answers from the +auth store with zero network and zero side effects; ``free_tier.provision`` is the explicit retry when +the boot bootstrap could not create the identity (desktop-only entry); ``free_tier.ack_notice`` persists the one-time notice flag on the free-tier identity itself, so it dies with that identity. Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. """ @@ -18,23 +20,17 @@ @_profile_scoped def _(rid, params: dict) -> dict: """``{has_guest, enabled, available, notice_pending, model, label}`` for the focused profile. - ``available`` = an identity exists AND ``nous.guest`` is on: the free tier (connectors, and the - model when nothing else carries inference) is there for this install. Whether inference actually - runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag. - ``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.""" + ``available`` = an identity exists AND the tier is on: the free tier (connectors, and the model + when nothing else carries inference) is there for this install. Whether inference actually runs + on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag. + ``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity. + + A pure read. The identity is created by the boot bootstrap (``free_tier_bootstrap``), never as + a side effect of a client polling this method (NS-845 Q1.2).""" try: from hermes_cli import anon_auth has_guest = anon_auth.has_guest() enabled = anon_auth.guest_enabled() - if enabled and not has_guest: - # The CLI sets the free tier up in the background beside an explicit provider at session - # setup (cli_agent_setup_mixin); a served backend has no such moment, so this read is the - # desktop's. One attempt per process, nothing waits on it: the answer below is the state - # as it stands, and a later read sees the identity once it lands. - try: - anon_auth.ensure_portal_identity(blocking=False) - except Exception as exc: - logger.debug("free tier background setup skipped: %s", exc) return _ok(rid, { "has_guest": has_guest, "enabled": enabled, "available": has_guest and enabled, "notice_pending": bool(has_guest and enabled and anon_auth.guest_notice_pending()), @@ -43,6 +39,32 @@ def _(rid, params: dict) -> dict: return _err(rid, 5090, str(e)) +@method("free_tier.provision") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Explicit retry of the free-tier set-up for the focused profile: adopt the shared store's + identity, else mint one (blocking, short timeout). The boot bootstrap normally did this already; + the desktop calls this when the record says the identity is missing (portal down at boot, gate + turned on later) and the user asks again. ``{has_guest, enabled}``; ``error`` when the portal + refused.""" + try: + from hermes_cli import anon_auth + enabled = anon_auth.guest_enabled() + error = None + if enabled and not anon_auth.has_guest(): + try: + anon_auth.ensure_portal_identity(explicit=True) + except Exception as exc: + logger.info("free tier provisioning failed: %s", exc) + error = str(exc) + payload = {"has_guest": anon_auth.has_guest(), "enabled": enabled} + if error: + payload["error"] = error + return _ok(rid, payload) + except Exception as e: + return _err(rid, 5092, str(e)) + + @method("free_tier.ack_notice") @_profile_scoped def _(rid, params: dict) -> dict: diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 7557e2aabe08a..8e92c56f33495 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -133,7 +133,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. | | `/topup` | Show your Nous balance and manage billing on the portal (replaces the old `/credits` and `/billing` commands). | | `/subscription` (alias: `/upgrade`) | **CLI only.** View your Nous plan and change it in the browser. | -| `/login` | Sign in with a Nous account, keeping your connectors. Runs off-turn: the consent link and code arrive in the session, and the sign-in settles when you approve it in the browser. See [Nous free tier](/user-guide/free-tier). | +| `/login` | Sign in with a Nous account. Runs off-turn: the consent link and code arrive in the session, and the sign-in settles when you approve it in the browser. See [Nous free tier](/user-guide/free-tier). | | `/insights` | Show usage insights and analytics (last 30 days) | | `/update` | Update Hermes Agent to the latest version. | | `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). | @@ -258,7 +258,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/sessions [all] [search ]` | List previous sessions for this chat; the active session appears with a `(current)` marker. `/sessions search ` filters by title/id match (most recently active first); `/sessions all` lists across origins (admin only — non-admins get a notice and the chat-scoped list). | | `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. | | `/topup` | Show your Nous balance and manage billing on the portal. | -| `/login` | Sign in with a Nous account, keeping your connectors. **Paired direct messages only** — in a group, channel, or broadcast-shaped platform Hermes refuses. On Slack use `/hermes login`. See [Nous free tier](/user-guide/free-tier). | +| `/login` | Sign in with a Nous account. **Paired direct messages only** — in a group, channel, or broadcast-shaped platform Hermes refuses. On Slack use `/hermes login`. See [Nous free tier](/user-guide/free-tier). | | `/whoami` | Show your slash command access level (admin / user). | | `/insights [days]` | Show usage analytics. | | `/reasoning [level\|show\|hide\|full\|clamp] [--global]` | Change reasoning effort (levels up to `max` / `ultra`) or toggle reasoning display (`full` / `clamp` included). `--global` persists to config. | diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index e784809cbb831..c03e502406b34 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -6,10 +6,17 @@ description: "What Hermes gives you before you add a key or sign in, how the fre # 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. +:::note Not on yet +The free tier is being rolled out. Until it is on for everyone, nothing on this page happens +unless the process was started with `HERMES_GUEST_ONBOARDING=1` in its environment; without it a +fresh install behaves exactly as before (the provider picker on first run). This note goes away +when the rollout completes. +::: + +A fresh Hermes install works before you paste an API key or sign in anywhere. When Hermes starts +it 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 @@ -23,6 +30,9 @@ through. `hermes setup` is still there when you want it; it is never forced. "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. +Background work (conversation compaction, chat titles, image understanding, and similar) runs on +`nous/welcome` too. + 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 @@ -50,9 +60,9 @@ The free tier is the last resort, never a preference. Any provider you configure | `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: +On an install that already has a provider, Hermes still sets the free tier up once at start so +connectors have something to authenticate with; your provider keeps doing inference. A one-time +notice says so: ```text Free Nous inference and connectors are now available. /model to try them, /login to sign in. @@ -91,12 +101,12 @@ hermes auth upgrade 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.` - If your default model was `nous/welcome`, a fourth line names the model your account now +3. Back in the terminal: `Signed in as you@example.com.` + If your default model was `nous/welcome`, a second line names the model your account now uses, for example `Default model is now upstage/solar-pro4:free.` -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. +Inference moves to your account's model catalog, paid tools unlock, and `hermes auth status` +shows your account instead of the free-tier line. `nous/welcome` stays with the free tier: an account that was using it lands on the recommended model for its plan (the same one a fresh `hermes model` pick would suggest), and a default model you chose yourself is left alone. If no recommendation is available at that moment, no default is @@ -125,7 +135,7 @@ The desktop app runs on the same free tier as the CLI and shows it in four place Signing in from any of those places opens one dialog. It shows a code and a link; open the link (or the browser the app opened), confirm in the portal, and the dialog ends with "Signed in as -you@example.com. Your connectors are kept." and the default model your account now uses. A +you@example.com." and the default model your account now uses. A sign-in you reject in the browser, a code that timed out, or a code replaced by a newer one each show their own message and leave you on the free tier. The model picker lists the free tier as one row, "Nous · free tier", with the single model `nous/welcome`; there is no sign-in action inside the @@ -161,7 +171,7 @@ needs it. | 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. | +| 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 at its next start. | | 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 @@ -176,7 +186,7 @@ itself. | `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 needs a Nous account.` | You called a paid Tool Gateway tool on the free tier. | `/login` in a chat, `hermes auth upgrade` in a terminal, 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. | +| The free tier stopped working after two weeks away | The free-tier identity expired (see below) and is replaced at the next start, or the next time a turn or connector finds it retired. | Nothing; start Hermes again. Connectors linked before the gap need to be linked again unless you had signed in. | ## Privacy