From 4bdd64b334adce2c071a1828fd2e195837fcc178 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:45:33 +0530 Subject: [PATCH] The free tier is created in one place, at boot, only behind HERMES_GUEST_ONBOARDING=1 (NS-847) (#107697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): close the free tier's gaps against the gateway's welcome-tier contract The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an anonymous account exactly one model on its own host, refuses everything else with a structured 429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and tells a signed-in account that still asks for `nous/welcome` what to switch to in an `x-nous-model-switch` header. Four client-side gaps against that contract: - Auxiliary calls were refused on every session. The auxiliary client asked the welcome host for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free` before each fallback. On the welcome host it now uses `nous/welcome` (its backing model covers auxiliary work) and skips Nous for vision, which the welcome model does not take. - The structured 429 body was never read. The classifier now parses `reason` / `retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited` are rate limits that honour `retry_after` and never rotate the free tier's only credential. The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and fall back instead of retrying or re-exchanging. The terminal paths say what happened and name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal). - The `x-nous-model-switch` header was ignored. The chat-completions transport records it beside the rate-limit and credits headers; the next call moves the session, and the config default when it still names `nous/welcome`, to the backing model the gateway named. - A guest fell back to the paid host. With `inference_base_url` absent from the exchange or outside the host allowlist, routing defaulted to inference-api, where every request is a 400. A guest now defaults to the welcome literal at the exchange, in the shared store's shape, and in effective routing. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8) * feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime rung now sets it up there instead of failing "not logged in", so the guided chat no longer races the root profile's first-run mint. nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever nothing else is configured; "on-request" mints only when the free tier is asked for by name (nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers — the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the connector token path — still adopt what the shared store holds, so every profile follows the one identity the guided setup created, but never create one on their own. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c) (cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165) * feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity (nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is configured. "explicit" means Hermes never creates one on its own: the only creator is the new provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and before the guided chat exists — so the identity lands in the root store every profile reads through and is there before any session asks for nous/welcome. That closes the race against the backend's own setup, and makes "only when the setup-bot flow is used" literally true. The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome (the hermes model row, --provider nous), which treated a model name as intent and was broader than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to sign in from. Implicit callers still adopt an identity the shared store holds, and a retired credential is replaced (a continuation, not a creation). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0) * fix(auth): remove the nous.guest_setup knob; the free tier is created on first use `nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`, unknown values read as `auto`, and under `explicit` a CLI-only install could never get an identity, which contradicts the first-run contract (first command mints, then chats). The mint race the knob accompanied is already benign: every caller takes the profile lock then the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which stays. `nous.guest` remains the only free-tier policy. Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through `ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config entries. The three policy tests that hold regardless of the knob are kept under `TestExplicitProvision`; the two that only tested the knob are deleted. (cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261) * fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch` moves the live session to that model and moves `config.yaml`'s default off the alias in the same step. The messaging gateway's fallback-eviction check compares the agent's model with the config default and evicts on any mismatch that is not a /model override, so when the config write did not land (unreadable config, lock) the cached agent was evicted once per turn, and prompt caching with it. `apply_model_switch` now stamps the alias it moved the session off on the agent, and `_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as deliberate, beside the existing /model override case. The check takes the agent and the config model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them. (cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954) * fix(auth): the free tier outranks implicit host credentials in provider resolution On a fresh install with a leftover ~/.aws profile, resolve_provider("auto") reached the Bedrock rung before the free-tier rung, so the first turn ran on Bedrock and failed 403 while the free tier was still being minted in the background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s, three retries, no answer; the next process then switched to nous/welcome. The free-tier rung now sits directly above the Bedrock chain: when nous.guest is on, an existing free-tier identity answers, else a blocking mint runs, and only then does the boto chain get a say. Everything above is unchanged and still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter pool, a logged-in active_provider. nous.guest: false skips the rung, and a failed mint still falls through to Bedrock and the no-provider guidance. Tests: six precedence cases (identity present, fresh mint, free tier off, env key still wins, sign-in still wins, failed mint falls through). The opt-out test now neutralizes the AWS chain like the precedence tests do; on a machine with ~/.aws it was failing for the same reason as the bug. Live after the fix, same Mac, AWS credentials visible, isolated shared store: identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in 11 s. (cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a) * fix(auth): review follow-ups for the free-tier rung (NS-829) - tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches the free tier off; its contract is the boto chain, and the free tier now sits above it. - gateway/run_notifications.py: the free-tier startup line reads auth.json before consulting the resolver, so a gateway boot on a machine with AWS credentials never mints or refreshes over the network. - hermes_cli/anon_auth.py: module docstring says where the free tier sits in the ladder instead of "the ladder is untouched". - tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of six (parametrized ladder cases; a failed mint that returns None or raises falls through to Bedrock). scripts/run_tests.sh on the five affected files: 147 passed, 0 failed. (cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11) * feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone The free tier is pre-GA. Until GA it must not exist for anyone who did not ask for it: no identity minted, no portal traffic, no free-tier copy on any surface. One environment variable now decides that, and one function reads it. `guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly "1"; only then does `nous.guest` (the user's off switch) get consulted. Every free-tier site already funnels through `guest_enabled()`, so the gate closes minting, routing, connector entitlement, status lines and the picker row in one place. With the variable unset, `resolve_provider("auto")` on a fresh install raises `no_provider_configured` exactly as upstream does. `HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted identities as a side effect of provider resolution, and `_has_any_provider_ configured` read them ahead of every other check, making the CLI a second reader of a flag that must have exactly one. `_forced_new_done` and the `force` parameter of `_reconcile_and_provision` go with them. Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847). Not a user preference: the variable is never written to config.yaml or .env and never shown in setup. It is deleted at GA together with its comment in anon_auth.py. This is a deliberate, temporary exception to the "no new HERMES_* env vars for non-secret config" rule. Tests: fixtures set the gate instead of deleting the old lever; one new invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "", "0", "true" and "new" all leave the tier off with zero portal calls, red on the previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the feature. * feat(auth): the free-tier identity is created in one place, at boot; every other site is a read Before this commit eight sites could create a Nous free-tier identity as a side effect of something else: resolving a provider, the CLI's first-run check, the CLI's session setup (in the background beside an own key), a connector bearer read, the desktop polling `free_tier.status`, the sign-in precondition, the desktop's `free_tier.provision`, and the dead-credential re-mint. A poll could mint. Provider resolution could hit the network. Two of them raced each other on a fresh install. Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator. `hermes serve` runs it on a daemon thread from `_lifespan` beside the other background boots; `cmd_chat` runs it synchronously before the first-run guard. It inventories credentials first (`resolve_provider("auto", skip_free_tier=True)`: what would carry inference if the free tier did not exist), creates the identity only when `guest_enabled()`, resolves inference, records a `SetupRecord` in process memory and broadcasts ONE `setup.ready` event. It runs on every boot; only the mint is gated. `ensure_portal_identity` now requires `explicit=True` and raises otherwise. Its callers are the bootstrap, the desktop's `free_tier.provision` (the explicit retry when the boot could not create the identity) and the two dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`, `managed_tool_gateway._replace_dead_guest_token`). The background thread path and `provision_free_tier` are deleted with their last callers. Reads that used to mint and now only read: `auth.py::resolve_provider` rung 7 (an existing identity still outranks the Bedrock chain, NS-829 ordering kept), `main.py::_has_any_provider_configured`, `cli_agent_setup_mixin._ensure_runtime_credentials`, `managed_tool_gateway.read_nous_access_token` (no identity -> None), `anon_sign_in.run_sign_in` (no identity -> Unavailable), `methods_free_tier` `free_tier.status`. `setup.status` answers from the record for the launch profile, blocking up to 8 s while the bootstrap is in flight so a client's first poll lands after the identity exists rather than racing it; a named profile, or a process that never ran the bootstrap, keeps today's live probe. The record's fields ride along additively (`ready`, `free_tier`, `other_providers`, `inference_provider`). Identity and inference are decoupled (NS-845 Q1.3): the mint sets `active_provider="nous"` only when the inventory found nothing else usable (`_mint_locked(carries_inference=)`); an adopted account always does. A token refresh no longer re-elects the provider it refreshed (`_save_provider_state_to_source` writes credentials, not the user's choice) — that write was how an own-key install ended up on the free tier after the first connector call. Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check), bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint), 62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and `provision_free_tier`), and a04b05260c (blocking mint in the resolver). Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847. Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps inference; reads never reach the portal; a refused mint is memoised), `free_tier.status` fails loudly if it ever calls the creator, the resolver stub fails loudly if resolution ever mints, `setup.status` reads the record, `skip_free_tier` proves the inventory question. The three sign-in tests for the deleted pre-mint collapse into one (`no identity -> Unavailable, zero portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20. * fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup" A free-tier identity carries $0 by design, so the portal seed reports `paid_access=False` for it. `is_free_tier_model` did not know the welcome host, read that as a depleted account, and every free-tier turn ended with the credits-depleted notice telling the user to top up an account they do not have. Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host (`anon_auth.route_is_welcome_host`) is the free tier. 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, so `("nous/welcome", )` stays False. Local data only, like the three rules above it. Restores the two contracts dropped by hermes-magic 674e11d1eaa (the prototype line ran without unit tests): the welcome host is free without any pricing evidence; the model name alone is not. The first is red without this fix. * fix(copy): free-tier text stops promising a connector transfer and never names the config key Sign-in copy on every surface said "Sign in to keep your connectors" and ended with "Your connectors are kept." The transfer registry that would make that true is empty (NS-821): nothing carries over today. The copy now says what signing in does give ("unlock more models and tools") and the completion line names the account, not a transfer. The docs page loses the "connectors carry over" paragraph for the same reason. The picker's off-state line exposed `nous.guest: false` and the word "guest"; user copy names the free tier only (R-USR-1). The docs page gains the pre-rollout note: until GA nothing on it happens without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and "replaced on next use" sentences now describe the boot bootstrap. zh is a strict locale: the `freeTier` block was English placeholder text copied from `en`; it is now Chinese. `connectorsKept` is renamed `completedBody` since it no longer talks about connectors. * feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1" as on. Until now nothing in the desktop set it, so a packaged app could never turn the free tier on, and a backend spawned by the app could disagree with the app about whether the tier was live. `electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled` is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has `--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch into a module constant. `desktopBackendSpawnEnv` wraps every backend env as the outermost call and writes the flag LAST, as "1" or an explicit "0", so no earlier spread (`process.env`, `backend.env`) can resurrect a stray value from the parent shell. Stamped onto all three spawn sites: the primary `serve` spawn, the pooled per-profile spawn, and the remote SSH `exec env ...` command (which gains ` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and the backend probes are not backend spawns and do not get it: a `hermes --tui` typed in the pane must not mint. The renderer learns the same fact read-only through the existing `hermes:launch-flags` sync IPC (`guestOnboarding`) and preload (`window.hermesDesktop.guestOnboardingEnabled`). Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding` maps to it in main). Two invariant tests on the pure helpers: only "1" or the argv flag enables; the spawn env carries "1"/"0" as the last word and preserves every other key. * feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll The backend's boot bootstrap now announces `setup.ready` once, after it has created (or refused) the free-tier identity and resolved the inference route. The renderer used to discover both by polling `setup.status`, `setup.runtime_check` and `free_tier.status` every 60 s from `useStatusSnapshot`; a fresh install's chip, notice strip and onboarding overlay could sit stale for up to a minute after boot, and three RPCs a minute per window kept asking a question whose answer changes only at boundaries the backend already announces. `handleLifecycleEvent` routes `setup.ready` (active source only, like `skin.changed`) to `notifySetupReady()`, a one-shot tick atom in `live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens to it and runs one readiness round at once (`setup.status` + `setup.runtime_check` + `free_tier.status`). The readiness legs also run once on open and on return from another app, as today. The 60 s tick keeps only `getStatus()`. `SetupStatusSnapshot` types the record's additive fields (`ready`, `free_tier`, `other_providers`, `inference_provider`); readiness semantics are unchanged and still key on `provider_configured` + `runtime_check`. Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one refresh from the active source and none from another; the snapshot hook's contract is three legs on open, one leg on the tick. * fix(cli): the banner names the free tier's model instead of "no model configured" The welcome banner prints before credentials resolve, so on a fresh install `model` is empty and the banner said, in red, "no model configured — run /model or hermes setup". Under the free tier that is false: the route is already known from local state (identity on disk, tier on), and the first message will run on `nous/welcome`. `_banner_left_lines` now asks the route the same question when `model` is empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the banner's 'no model configured' line reads the resolved route"). Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`; gate off -> the red line, zero portal calls. * fix(aux): vision on the free tier uses nous/welcome too The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the welcome host would have sent every image step past the free tier for no reason, so the auxiliary client pins the route's one model for every lane. A backing model that takes no images answers with the upstream's own error, which the ladder handles as it always has. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415) * fix(gateway): hermes gateway run is a boot owner of the free tier too Rung 5 made every demand-time free-tier site a read: resolve_provider, the connector token, the /login precondition. That is only correct if every process that can reach those sites ran the bootstrap first. The CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone messaging gateway did not. A fresh HERMES_HOME with the gate on and `hermes gateway run` reached provider resolution with no identity to consume, and /login returned Unavailable. Reported by @andrexibiza on #107697 (P1). GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an executor thread right after startup recovery and BEFORE any adapter connects, so a fast first DM cannot arrive with nothing to resolve. It is its own step, not part of the turn-machinery warm-up: the warm-up is an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0); the bootstrap is correctness and must always run. With the gate unset it is a local inventory and no network. Live, real GatewayRunner.start against a fake portal in a fresh home: gate on -> 1 create, identity persisted, resolve_runtime_provider=nous, /login precondition sees the identity gate off -> 0 portal calls, no identity, no_provider_configured Before the fix the gate-on row was identical to the gate-off row. Test: the bootstrap seam runs before _start_prefilter_platforms and delegates to the one creator. Red on 5554eb6993 (no seam), green here. --------- Co-authored-by: Robin Fernandes Co-authored-by: Claude Fable 5.1 --- agent/auxiliary_client.py | 43 ++- agent/chat_completion_helpers.py | 1 + agent/credits_tracker.py | 16 + agent/error_classifier.py | 36 ++ agent/rate_limit_credits.py | 13 + agent/turn_api_call.py | 7 + agent/turn_recovery.py | 30 +- .../electron/guest-onboarding-flag.test.ts | 49 +++ apps/desktop/electron/guest-onboarding.ts | 23 ++ apps/desktop/electron/main.ts | 102 ++--- apps/desktop/electron/preload.ts | 4 + apps/desktop/electron/remote-lifecycle.ts | 15 +- .../gateway-event/lifecycle.test.ts | 105 ++++++ .../gateway-event/lifecycle.ts | 17 +- .../app/settings/billing/use-billing-state.ts | 2 +- .../shell/hooks/use-status-snapshot.test.ts | 54 ++- .../app/shell/hooks/use-status-snapshot.ts | 84 +++-- .../free-tier/sign-in-dialog.test.tsx | 2 +- .../components/free-tier/sign-in-dialog.tsx | 2 +- apps/desktop/src/global.d.ts | 4 + apps/desktop/src/i18n/en.ts | 8 +- apps/desktop/src/i18n/types.ts | 2 +- apps/desktop/src/i18n/zh.ts | 78 ++-- apps/desktop/src/lib/runtime-readiness.ts | 7 + apps/desktop/src/store/live-sync.ts | 10 + gateway/run_agent_cache.py | 12 +- gateway/run_notifications.py | 10 +- gateway/run_startup.py | 13 + gateway/run_turn.py | 2 +- hermes_cli/anon_auth.py | 351 +++++++++++++----- hermes_cli/anon_sign_in.py | 33 +- hermes_cli/auth.py | 54 ++- hermes_cli/auth_constants.py | 4 + hermes_cli/auth_nous.py | 13 +- hermes_cli/banner.py | 14 + hermes_cli/cli_agent_setup_mixin.py | 13 +- hermes_cli/free_tier_bootstrap.py | 157 ++++++++ hermes_cli/main.py | 30 +- hermes_cli/model_setup_flows.py | 2 +- hermes_cli/web_server.py | 8 + tests/agent/test_bedrock_integration.py | 3 + tests/agent/test_credits_policy.py | 22 ++ tests/agent/test_error_classifier.py | 80 ++++ .../test_nous_welcome_client_contract.py | 163 ++++++++ tests/gateway/test_free_tier_gateway_boot.py | 52 +++ .../gateway/test_free_tier_startup_notice.py | 2 +- tests/gateway/test_login_command.py | 6 +- .../gateway/test_model_switch_persistence.py | 25 +- tests/gateway/test_status_free_tier_line.py | 2 +- tests/hermes_cli/test_anon_auth_core.py | 235 ++++++++---- tests/hermes_cli/test_anon_desktop_signin.py | 8 +- tests/hermes_cli/test_anon_first_notice.py | 2 +- tests/hermes_cli/test_anon_picker.py | 2 +- tests/hermes_cli/test_anon_sign_in_flow.py | 42 +-- tests/hermes_cli/test_anon_surfaces.py | 2 +- tests/hermes_cli/test_anon_upgrade.py | 18 +- tests/hermes_cli/test_banner.py | 25 ++ tests/hermes_cli/test_login_cli_command.py | 8 +- tests/hermes_cli/test_provider_precedence.py | 55 +++ tests/test_tui_gateway_server.py | 30 ++ tests/tui_gateway/test_free_tier_rpc.py | 50 ++- tools/managed_tool_gateway.py | 19 +- tui_gateway/methods_config.py | 22 +- tui_gateway/methods_free_tier.py | 50 ++- website/docs/reference/slash-commands.md | 4 +- website/docs/user-guide/free-tier.md | 38 +- 66 files changed, 1892 insertions(+), 503 deletions(-) create mode 100644 apps/desktop/electron/guest-onboarding-flag.test.ts create mode 100644 apps/desktop/electron/guest-onboarding.ts create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts create mode 100644 hermes_cli/free_tier_bootstrap.py create mode 100644 tests/agent/test_nous_welcome_client_contract.py create mode 100644 tests/gateway/test_free_tier_gateway_boot.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a6af92bda3..21fbc21507 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 @@ _RATE_LIMIT_KEYWORDS = ( _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 2eeb6c89e9..acddfbf722 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2707,6 +2707,7 @@ class _StreamingCall(StreamingWaitMonitor): 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 cd6005cbd8..91f3114b7d 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 8c1d0030ea..09488986dd 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -10,6 +10,7 @@ from __future__ import annotations 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 9dc0f2d45e..44c6f7a200 100644 --- a/agent/rate_limit_credits.py +++ b/agent/rate_limit_credits.py @@ -49,6 +49,19 @@ class RateLimitCreditsMixin: """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 194ee22f95..379a5e4509 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -219,6 +219,13 @@ def nous_rate_limit_guard( ) 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 38019505fe..b71d7862c8 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 0000000000..95954fbb9a --- /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 0000000000..0610dd5c47 --- /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 d0c2e18513..fdf7239b7b 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 a8ebb1d71f..09519f21c8 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 a9b2d9b4fd..cf38b4752d 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 0000000000..7d655fe74e --- /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 2395498d94..16633910dc 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 d333dbf3dc..c30a5816ac 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 820cc285c6..f05979d306 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 c0917e9309..bf8b3b6b16 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 8918be7a85..0d67ac6370 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 2a765e49b4..1983739b8a 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 0089b100df..c5538e3bc5 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 0948fa3618..959dad2026 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 0ebca0ec6a..3820b8b181 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 ae9a80e520..ff405d9d97 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 e07c150842..cf8d9665ea 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 844c4d39f2..d06bc35c0e 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 5c15a264a5..71814d7c9e 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -218,10 +218,16 @@ class GatewayAgentCacheMixin: 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 fb0fc4995c..41f8a55ee6 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -747,14 +747,16 @@ class GatewayNotificationsMixin: 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 29fd2c252f..8ce482df1e 100644 --- a/gateway/run_startup.py +++ b/gateway/run_startup.py @@ -90,6 +90,13 @@ class GatewayStartupMixin: 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 @@ class GatewayStartupMixin: 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 1f222be9f9..472b008a7c 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -3275,7 +3275,7 @@ class GatewayTurnMixin: _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 0953877f92..5460034dcc 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. +"""Nous free-tier 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. +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 @@ from __future__ import annotations 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 @@ ANON_SECRET_HEADER = "x-anonymous-api-secret" # The shared secret gates the anonymous surface during its integration phase. It is a deployment # secret (Sid's), read from the environment only. ANON_SECRET_ENV = "HERMES_ANON_API_SECRET" -# Dev lever: "1" makes the guest carry inference even when explicit providers exist; "new" also -# bypasses the shared store and mints a fresh guest for this process. Overrides ``nous.guest: false``. -FORCE_GUEST_ENV = "HERMES_FORCE_GUEST" +# 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). + store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already). - 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). + ``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 dce6fa8ab4..c1d677e217 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -11,7 +11,7 @@ from typing import Any, Callable, ClassVar, ContextManager, Dict, Iterator, Opti 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_UNAVAILABLE = "The free tier is not available right now; run `hermes aut 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 19f83acfd6..4424058574 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 9678da216c..73ce99b961 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -52,6 +52,10 @@ AUTH_LOCK_TIMEOUT_SECONDS = 15.0 # 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 faef278718..97057274f3 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -21,7 +21,7 @@ from urllib.parse import urlparse 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 b4410592e6..c97d5dd15c 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 _banner_left_lines(model: str, cwd: str, session_id, context_length, provide 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 1dc6432bdf..93bdd481c7 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -199,16 +199,9 @@ class CLIAgentSetupMixin: 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 0000000000..eca44e5b2f --- /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 ca0cd6f906..167830d641 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 a3dbbe35fa..45fc4f47e0 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -292,7 +292,7 @@ def _model_flow_nous(config, current_model="", args=None): 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 5ffffc9a69..72bfab2b4c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -235,6 +235,14 @@ async def _lifespan(app: "FastAPI"): 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 fb4476447b..31c21cf49d 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -127,6 +127,9 @@ class TestResolveProvider: 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 231d900ec1..d7db358e89 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -339,6 +339,28 @@ class TestIsFreeTierModel: 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 828e83e108..13cb0ab873 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -1712,3 +1712,83 @@ class TestServerInjectedParameterRejection: 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 0000000000..356ea223bd --- /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 0000000000..793197f56b --- /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 2f50710a3a..0d703d9fef 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 da2b0b825f..4fb522c074 100644 --- a/tests/gateway/test_login_command.py +++ b/tests/gateway/test_login_command.py @@ -188,7 +188,7 @@ async def test_a_failed_code_push_does_not_abort_the_drain(monkeypatch, caplog): 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 ea0a175c1a..a668518a3e 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -127,7 +127,9 @@ class TestApplySessionModelOverride: 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 @@ class TestIsIntentionalModelSwitch: "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 4202668362..f979e2613e 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 144a44869c..390c9a36a6 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -39,6 +39,8 @@ class FakePortal: 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 @@ class FakePortal: 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 portal(monkeypatch, tmp_path): 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 @@ class TestIdentityLifecycle: 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 @@ class TestIdentityLifecycle: 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 @@ class TestResolverIsUnchanged: 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 @@ class TestTokenAcquisitionSeam: 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 @@ class TestTokenAcquisitionSeam: 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 @@ class TestLogout: 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 @@ class TestRotationNeverRewritesTheConversationModel: 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 @@ class TestIdentityOfRecordIsTheSharedStore: 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 @@ class TestIdentityOfRecordIsTheSharedStore: 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 @@ class TestConnectorTokenPath: 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 819817a57e..f1849a7fce 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 b147952fa7..6d0c956cd5 100644 --- a/tests/hermes_cli/test_anon_first_notice.py +++ b/tests/hermes_cli/test_anon_first_notice.py @@ -51,7 +51,7 @@ class _NoticeCLI(CLIAgentSetupMixin): @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 ee7f0803f8..4004f9b3b1 100644 --- a/tests/hermes_cli/test_anon_picker.py +++ b/tests/hermes_cli/test_anon_picker.py @@ -29,7 +29,7 @@ GUEST_STATE = { 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 fa27ee3fbe..5cc28a6211 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 165b63945b..19cf5d140f 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 1b1fc5c120..15ffa1df59 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 portal(monkeypatch, tmp_path): 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 @@ class TestUpgrade: 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 @@ class TestUpgrade: 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 @@ class TestSignInCompletionSettlesTheModel: 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 @@ class TestSignInCompletionSettlesTheModel: 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 9127935dd6..4ac834166c 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 d914051728..1af19eca7e 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 test_the_drain_writes_to_the_console_captured_at_start(monkeypatch): 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 test_the_live_tui_drain_prints_through_cprint_instead_of_the_captured_consol 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 16a59be469..4f3f13d4c6 100644 --- a/tests/hermes_cli/test_provider_precedence.py +++ b/tests/hermes_cli/test_provider_precedence.py @@ -90,3 +90,58 @@ class TestProviderPrecedence: 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 69cdc03432..443f3e5ce8 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 116e900268..bbe94cfbb5 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 458c4daf31..5c303c57ae 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 9458478448..424fb48210 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 3cf66efb9c..c71590ce5d 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 = _registry.profile_scoped @_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 7557e2aabe..8e92c56f33 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 e784809cbb..c03e502406 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