From 3b01b4ce0fcfd254c8251e05ecd0a86eecbc0f31 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:45:32 +0530 Subject: [PATCH] feat(desktop): Nous free tier on Hermes Desktop (#105260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): free-tier state over RPC, status routes that name it, and a sign-in that keeps connectors The desktop learns about the Nous free tier by reading local auth state (pull): free_tier.status answers has_guest / enabled / carries_inference / notice_pending with zero network, and free_tier.ack_notice persists the one-time notice flag on the identity itself. setup.runtime_check reports free_tier for the selected route; /api/portal, the Nous card in /api/providers/oauth and billing.state carry free_tier (billing answers the free tier locally instead of a portal call that can only fail). The free-tier picker row carries an explicit free_tier_row flag and is never priced or locked. POST /api/providers/oauth/nous/start over a free-tier identity registers the connector transfer and returns its code and consent URL; the poller waits for the transfer before the token grant, persists the account, runs settle_after_upgrade, and the poll response gains reason, account_email and model. * feat(desktop): free tier on Hermes Desktop: ready screen, notice strip, status chip, Billing view, one sign-in dialog The renderer reads the free tier from free_tier.status (pull) into one store; the first-launch intro is the same state rendered two ways, keyed on the backend's one-time flag: the onboarding overlay opens on a ready screen when the free tier carries inference, else a one-time strip above the composer. Settings > Billing gains a free_tier view (notice with one Sign in, Plan / Model / Connectors summary, plan card, footnote; no payment or usage rows). A status-bar chip names the tier and model while it carries inference. Every entry point opens one claimed sign-in dialog that drives the extended oauth/nous route and maps the poll's status and reason to the ruled screens; Done settles billing, model options, providers and re-homes a session still on nous/welcome. The picker badge also fires on free_tier_row. Docs: Desktop section in the free-tier guide, AGENTS notes. * fix(desktop): free_tier.status starts the free tier's background setup when no identity exists A served backend has no session-setup moment like the CLI's, so beside an explicit provider the free tier was never set up on the desktop: no connectors, no notice strip. The first status read now starts the same one-attempt background setup; the call itself never waits. * fix(desktop): one Sign in on the Billing page; Settings > Providers names the free tier, never Connected The free-tier plan card is the what-you-get text alone (the notice carries the page's one Sign in). The Nous provider row reads Nous · free tier with a Free tier tag while the identity is the free tier, instead of Nous Portal · Connected. * fix(desktop): Settings > Providers never files the free tier under Connected * fix(desktop): the intro's shape is keyed on the route, not on the identity free_tier.status reports available (an identity exists and the tier is on); whether inference runs on the free tier is setup.runtime_check.free_tier, keyed on the resolved endpoint. The ready screen shows when that route is the free tier; the composer strip when the user's own provider carries inference. An own-key install used to get the ready screen. * docs(desktop): say what the free-tier chip is keyed on * fix(desktop): the featured Nous row's pitch on the free tier says what signing in adds * fix(desktop): a cancelled or superseded sign-in attempt can no longer change the identity or hide the intro Four lifecycle holes from review. The Nous poller checks the session's cancelled flag after the transfer wait, after the token grant, and once more under the session lock together with the save, so a sign-in the user abandoned never persists. The renderer's sign-in store carries an attempt generation that every continuation checks after each await, so a poll from a closed attempt cannot publish over the one on screen (and its backend session is cancelled). The ready screen comes down only after the backend recorded the acknowledgement. A composer still mounted takes over the notice claim when its owner unmounts. One thin test per hole. --- apps/desktop/AGENTS.md | 14 + apps/desktop/src/AGENTS.md | 9 + .../app/chat/composer/status-stack/index.tsx | 15 + apps/desktop/src/app/contrib/wiring.tsx | 5 + .../settings/billing/current-plan-card.tsx | 2 +- .../src/app/settings/billing/index.tsx | 36 +- .../desktop/src/app/settings/billing/types.ts | 16 +- .../billing/use-billing-state.test.ts | 26 ++ .../app/settings/billing/use-billing-state.ts | 70 +++- .../src/app/settings/providers-settings.tsx | 9 +- .../shell/hooks/use-status-snapshot.test.ts | 12 +- .../app/shell/hooks/use-status-snapshot.ts | 9 +- .../app/shell/hooks/use-statusbar-items.tsx | 27 ++ .../components/boot-failure-overlay.test.tsx | 3 +- .../free-tier/notice-owner.test.tsx | 21 ++ .../src/components/free-tier/notice-strip.tsx | 107 ++++++ .../free-tier/sign-in-dialog.test.tsx | 80 +++++ .../components/free-tier/sign-in-dialog.tsx | 270 +++++++++++++++ .../gateway-connecting-overlay.test.tsx | 3 +- apps/desktop/src/components/model-picker.tsx | 7 +- .../src/components/onboarding/flow.tsx | 2 +- .../onboarding/free-tier-intro.test.ts | 76 +++++ .../src/components/onboarding/index.test.tsx | 6 +- .../src/components/onboarding/index.tsx | 116 ++++++- .../src/components/onboarding/providers.tsx | 31 +- apps/desktop/src/i18n/en.ts | 43 +++ apps/desktop/src/i18n/types.ts | 48 +++ apps/desktop/src/i18n/zh.ts | 45 +++ apps/desktop/src/lib/runtime-readiness.ts | 24 ++ .../src/store/free-tier-sign-in.test.ts | 66 ++++ apps/desktop/src/store/free-tier-sign-in.ts | 313 ++++++++++++++++++ apps/desktop/src/store/free-tier.ts | 123 +++++++ apps/desktop/src/store/onboarding.test.ts | 1 + apps/desktop/src/store/onboarding.ts | 69 +++- apps/desktop/src/types/hermes.ts | 43 ++- hermes_cli/inventory.py | 4 + hermes_cli/model_switch_providers.py | 4 + hermes_cli/web_routers/oauth.py | 57 +++- hermes_cli/web_routers/status.py | 3 + hermes_cli/web_server_oauth.py | 81 ++++- tests/hermes_cli/test_anon_desktop_signin.py | 108 ++++++ tests/hermes_cli/test_anon_picker.py | 15 + tests/tui_gateway/test_free_tier_rpc.py | 85 +++++ tui_gateway/billing_view.py | 11 +- tui_gateway/methods_config.py | 7 +- tui_gateway/methods_free_tier.py | 59 ++++ tui_gateway/methods_session.py | 17 +- tui_gateway/server.py | 4 +- website/docs/user-guide/free-tier.md | 23 ++ 49 files changed, 2142 insertions(+), 83 deletions(-) create mode 100644 apps/desktop/src/components/free-tier/notice-owner.test.tsx create mode 100644 apps/desktop/src/components/free-tier/notice-strip.tsx create mode 100644 apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx create mode 100644 apps/desktop/src/components/free-tier/sign-in-dialog.tsx create mode 100644 apps/desktop/src/components/onboarding/free-tier-intro.test.ts create mode 100644 apps/desktop/src/store/free-tier-sign-in.test.ts create mode 100644 apps/desktop/src/store/free-tier-sign-in.ts create mode 100644 apps/desktop/src/store/free-tier.ts create mode 100644 tests/hermes_cli/test_anon_desktop_signin.py create mode 100644 tests/tui_gateway/test_free_tier_rpc.py create mode 100644 tui_gateway/methods_free_tier.py diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index fb1fc39b5b..a33a846e35 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -209,3 +209,17 @@ actually run rather than inventing a command; when in doubt, read the scripts. locales? If any answer is "not sure," that's the part to go verify. + +## Nous free tier: state is pulled, never latched in the renderer + +The free tier (a Nous identity with no account, `hermes_cli/anon_auth.py`) reaches the renderer +through one JSON-RPC pair: `free_tier.status` (has_guest, enabled, available, +notice_pending, model, label) read from local auth state with zero network, and +`free_tier.ack_notice`, which persists the one-time notice flag on the identity itself. The +first-launch ready screen and the own-key strip are the SAME state rendered for two situations, +keyed on `notice_pending`; there is no localStorage latch, so the CLI and the desktop cannot +disagree about whether the notice was shown. Sign-in goes through the existing +`POST /api/providers/oauth/nous/start` + poll route, which over a free-tier identity registers the +connector transfer and reports `reason`, `account_email` and `model` on completion; every entry +point (Billing, status chip, ready screen) opens the one free-tier sign-in dialog. Never branch on +provider display names: the picker row carries `free_tier_row`, status cards carry `free_tier`. diff --git a/apps/desktop/src/AGENTS.md b/apps/desktop/src/AGENTS.md index 523c13c0b7..9afe8dc8dd 100644 --- a/apps/desktop/src/AGENTS.md +++ b/apps/desktop/src/AGENTS.md @@ -81,3 +81,12 @@ in `src/plugins/hermes-bots/`: `canonical-chat-registry.test.ts` (tripwire: the reads/writes a stored pointer), `canonical-chat-creation.test.ts`, `canonical-chat-adopt-on-conflict.test.ts`, `bot-row-opens-canonical-chat.test.ts`, `hide-bot-chats.test.ts`; plus repo-root `tests/tui_gateway/test_profiles_list_canonical_session.py`. + +## Free tier surfaces (`src/store/free-tier*.ts`, Billing, statusbar chip, onboarding ready screen) + +`$freeTierStatus` mirrors `free_tier.status` (pull; refreshed with the status snapshot and after a +sign-in). `deriveBillingView` branches on `billing.free_tier` BEFORE `logged_in` (status +`free_tier`: notice + one Sign in, Plan/Model/Connectors summary, no payment or usage rows). The +sign-in dialog is a single claimed owner (first mount wins, like the real-profile consent prompt); +its states map 1:1 to the poll route's `status` + `reason`. Copy is the ruled free-tier copy: never +"guest", "anonymous", "claim" or "Nous Portal" in user-facing text. diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index ee47f566bc..877150cedd 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -8,6 +8,7 @@ import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/u import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' +import { FreeTierNoticeStrip, useFreeTierNoticeOwner } from '@/components/free-tier/notice-strip' import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -26,6 +27,7 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -107,6 +109,12 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) + const freeTierStatus = useStore($freeTierStatus) + const freeTierRoute = useStore($freeTierRoute) + // One claimed owner across every mounted composer, so a split view shows the + // notice once — and a non-owning stack adds no empty row to its card. + const ownsFreeTierNotice = useFreeTierNoticeOwner() + const freeTierNotice = ownsFreeTierNotice && freeTierStripPending(freeTierStatus, freeTierRoute) const isStructuredSupported = controlEntry?.capability === 'supported' @@ -183,6 +191,13 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat sections.push({ key: 'billing', node: }) } + // Below the billing wall (a blocker outranks an offer), above everything the + // session itself is doing. The strip retires itself the moment any of its + // actions acks the notice. + if (freeTierNotice) { + sections.push({ key: 'free-tier', node: }) + } + const hasControlContent = Boolean( controlEntry && (controlEntry.error || diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 1a560faa5d..229d18ba24 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -19,6 +19,7 @@ import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { ConfirmHost } from '@/components/confirm-host' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { FindBar } from '@/components/find-bar' +import { FreeTierSignInDialog } from '@/components/free-tier/sign-in-dialog' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { NotificationStack } from '@/components/notifications' import { DesktopOnboardingOverlay } from '@/components/onboarding' @@ -1194,6 +1195,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { requestGateway={requestGateway} /> )} + {/* One host for every free-tier sign-in entry point (Settings › Billing, + the statusbar chip, the first-launch intro). It owns the flow; the + entry points only record the intent. */} + {!isAuxiliaryWindow() && } void
{plan.action && ( - )} diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 040d37b3b4..37674e01cc 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -87,18 +87,25 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
{notice.message}
- {notice.action && ( - - )} + {notice.action && + (notice.action.onSelect ? ( + // In-app action (free-tier sign-in): a plain button, no external-link + // glyph — nothing leaves the app. + + ) : ( + + ))}
) } @@ -517,6 +524,11 @@ function BillingSettingsContent({ {view.plan && ( setSubView('plans')} plan={view.plan} /> + {view.planFootnote && ( +
+ {view.planFootnote} +
+ )}
)} diff --git a/apps/desktop/src/app/settings/billing/types.ts b/apps/desktop/src/app/settings/billing/types.ts index 78d888fe73..3d25389eab 100644 --- a/apps/desktop/src/app/settings/billing/types.ts +++ b/apps/desktop/src/app/settings/billing/types.ts @@ -7,8 +7,8 @@ import type { BillingMonthlyCap, BillingMutationResponse, BillingRefusalCode, - BillingStateResponse, ChargeFailureReason, + BillingStateResponse as SharedBillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionTierOption, @@ -16,6 +16,18 @@ import type { UsageModelData } from '@hermes/shared/billing' +/** + * The gateway's `billing.state` payload as THIS app reads it: the shared shape + * plus the free-tier fields newer gateways add. When `free_tier` is true there + * is no account behind the call — `logged_in` is false and there is no balance, + * card or usage — so the free-tier branch must be read before the logged-out + * one. Both fields are absent on older gateways. + */ +export type BillingStateResponse = SharedBillingStateResponse & { + free_tier?: boolean + free_tier_model?: null | string +} + export type { BillingAutoReload, BillingCardInfo, @@ -25,8 +37,8 @@ export type { BillingMonthlyCap, BillingMutationResponse, BillingRefusalCode, - BillingStateResponse, ChargeFailureReason, + SharedBillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionTierOption, diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index a132e057db..6a9c2349cb 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -171,6 +171,32 @@ describe('deriveBillingView', () => { expect(view.usageRows).toEqual([]) }) + it('derives the free-tier view before the logged-out one, with nothing to pay', () => { + // A free-tier install is logged_in:false, so this branch must win — otherwise + // the generic "connect your account" notice sends the user to the portal. + const view = deriveBillingView( + okBilling({ ...loggedOutBillingState, free_tier: true, free_tier_model: 'nous/welcome' }), + okSubscription(loggedOutSubscriptionState) + ) + + expect(view.status).toBe('free_tier') + expect(view.notice).toMatchObject({ title: "You're on the Nous free tier", tone: 'info' }) + expect(view.notice?.action?.label).toBe('Sign in') + expect(view.summary).toEqual([ + { label: 'Plan', value: 'Free tier' }, + { label: 'Model', value: 'nous/welcome' }, + { label: 'Connectors', tone: 'primary', value: 'Included' } + ]) + expect(view.plan).toMatchObject({ tierName: 'Nous · free tier' }) + expect(view.plan?.action).toBeUndefined() + expect(view.planFootnote).toContain('no balance and nothing to pay') + expect(view.paymentRow).toBeUndefined() + expect(view.topupRow).toBeUndefined() + expect(view.refillRow).toBeUndefined() + expect(view.tiers).toEqual([]) + expect(view.usageRows).toEqual([]) + }) + it('derives a refusal notice when billing.state is unavailable', () => { const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState)) 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 3ccc8693ac..d333dbf3dc 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { fmtDate } from '@/lib/time' +import { FREE_TIER_MODEL } from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import type { BillingRefusal, BillingResult } from './api' import { useBillingApi } from './api' @@ -30,16 +32,18 @@ const BILLING_QUERY_OPTIONS = { } as const export interface BillingSummaryItemView { - label: 'Auto-refill' | 'Balance' | 'Plan' + label: 'Auto-refill' | 'Balance' | 'Connectors' | 'Model' | 'Plan' tone?: 'muted' | 'primary' value: string } export interface BillingNoticeView { - action?: { - label: string - url: string - } + /** Either an external portal hop (`url`) or an in-app action (`onSelect`) — + * a discriminated pair, so a consumer never has to guard for "both" or + * "neither". */ + action?: + | { label: string; onSelect: () => void; url?: undefined } + | { label: string; onSelect?: undefined; url: string } message: string title: string /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ @@ -96,7 +100,18 @@ export type BillingPlanCardView = { pending?: PendingPlanTransition price?: string tierName: string -} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } }) +} & ( + | { + // `onSelect` overrides the card's default "open the plans grid" action — + // the free-tier card signs in instead. Absent = the plans grid. + action: { label: string; onSelect?: () => void } + link?: undefined + } + | { action?: undefined; link: { label: string; url: string } } + // The free-tier card is the "what you get" text alone: the page's one Sign in lives on the + // notice above it, so the card carries neither an action nor a link. + | { action?: undefined; link?: undefined } +) interface BillingPlanTierBase { creditsDisplay?: string @@ -137,9 +152,11 @@ export interface BillingView { paymentRow?: BillingAccountRowView /** Current-plan card (Plan section). Absent until billing.state resolves. */ plan?: BillingPlanCardView + /** Small print under the Plan section. Only the free-tier view sets it. */ + planFootnote?: string /** Automatic-refill section row. */ refillRow?: BillingAccountRowView - status: 'loading' | 'logged_out' | 'normal' | 'refusal' + status: 'free_tier' | 'loading' | 'logged_out' | 'normal' | 'refusal' summary: BillingSummaryItemView[] /** Live tier catalog for the plans sub-view (empty when unavailable). */ tiers: BillingPlanTierView[] @@ -196,6 +213,13 @@ export function deriveBillingView( const billing = stateResult.data const subscription = subscriptionResult?.ok ? subscriptionResult.data : null + // Read BEFORE the logged-out branch: a free-tier install has no account, so + // `logged_in` is false and the generic "connect your account" notice would + // otherwise win and tell the user to go to the portal. + if (billing.free_tier) { + return freeTierView(billing) + } + if (!billing.logged_in || subscription?.logged_in === false) { return { notice: { @@ -297,6 +321,38 @@ function emptySummary(): BillingSummaryItemView[] { ] } +/** + * The no-account state: nothing is owed, nothing is owned, and every money + * control would be a lie. So the page collapses to one notice, a three-item + * summary, and a single plan card whose only action is signing in — no payment, + * credits, auto-refill or usage sections at all. + */ +function freeTierView(billing: BillingStateResponse): BillingView { + return { + notice: { + action: { label: 'Sign in', onSelect: openFreeTierSignIn }, + message: 'Sign in to keep your connectors and unlock more.', + title: "You're on the Nous free tier", + tone: 'info' + }, + plan: { + caption: + 'Runs on nous/welcome with connectors included. Signing in keeps your connectors and adds the tools that need an account and every other model.', + tierName: 'Nous · free tier' + }, + planFootnote: + 'The free tier has no balance and nothing to pay. Payment and usage appear when you sign in with a Nous account.', + status: 'free_tier', + summary: [ + { label: 'Plan', value: 'Free tier' }, + { label: 'Model', value: billing.free_tier_model ?? FREE_TIER_MODEL }, + { label: 'Connectors', tone: 'primary', value: 'Included' } + ], + tiers: [], + usageRows: [] + } +} + function refusalNotice(refusal: BillingRefusal): BillingNoticeView { const resolved = resolveRefusal(refusal) const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 86562e26cb..f81d726ebb 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -157,13 +157,16 @@ function OAuthPicker({ const select = (p: OAuthProvider) => startManualProviderOAuth(p.id, profile) - const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null + // The free tier holds a token but no account: it is never "connected"; the featured Nous row + // names it (Nous · free tier) and offers the sign-in that keeps its connectors. + const isConnected = (p: OAuthProvider) => Boolean(p.status?.logged_in) && p.status?.free_tier !== true + const featured = ordered.find(p => p.id === FEATURED_ID && !isConnected(p)) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Keep connected accounts grouped and always visible; only the unconnected // providers hide behind the disclosure, so the page leads with what's set up. // Both lists preserve `sortProviders` order (curated priority, then name). - const connected = rest.filter(p => p.status?.logged_in) - const others = rest.filter(p => !p.status?.logged_in) + const connected = rest.filter(isConnected) + const others = rest.filter(p => !isConnected(p)) const collapsible = others.length > 0 const showOthers = !collapsible || showAll 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 94859b09ad..820cc285c6 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 @@ -54,7 +54,8 @@ describe('useStatusSnapshot', () => { await flushAsync() expect(getStatus).toHaveBeenCalledOnce() - expect(requestGateway).toHaveBeenCalledTimes(2) + // One refresh round = setup.status + setup.runtime_check + free_tier.status. + expect(requestGateway).toHaveBeenCalledTimes(3) }) it('keeps the last authoritative readiness through a transient RPC failure', async () => { @@ -199,13 +200,14 @@ describe('useStatusSnapshot', () => { renderHook(() => useStatusSnapshot('open', requestGateway)) await flushAsync() - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + // Three legs per round: setup.status, setup.runtime_check, free_tier.status. + expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { setup.resolve({ provider_configured: true }) @@ -216,11 +218,11 @@ describe('useStatusSnapshot', () => { await act(async () => { await vi.advanceTimersByTimeAsync(59_999) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { await vi.advanceTimersByTimeAsync(1) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(4) + expect(requestGatewayMock).toHaveBeenCalledTimes(6) }) }) 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 5fdc639177..c0917e9309 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -2,6 +2,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 type { StatusResponse } from '@/types/hermes' // Statusbar health is ambient chrome, not live data — nothing the user acts on @@ -59,7 +60,12 @@ export function useStatusSnapshot( // race newer healthy results. const [statusResult, inferenceResult] = await Promise.allSettled([ getStatus(), - gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null) + 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) ]) if (cancelled) { @@ -82,6 +88,7 @@ export function useStatusSnapshot( // became unconfigured. Keep the last authoritative result instead // of flashing "Inference not ready" during a gateway flap. setInferenceStatus(inference) + setFreeTierRoute(inference.freeTier) } } } finally { diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 1714e62e57..ea96b309b0 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -10,6 +10,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { useContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { useSystemResourcesStatusbarItem } from '@/app/shell/system-resources-statusbar' import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store' +import { Badge } from '@/components/ui/badge' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' @@ -33,6 +34,8 @@ import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { resolveVersionStatus } from '@/lib/version-status' import { copyFilePath, revealFile } from '@/store/file-actions' +import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -100,6 +103,7 @@ export function useStatusbarItems({ }: StatusbarItemsOptions) { const { t } = useI18n() const copy = t.shell.statusbar + const freeTierCopy = t.freeTier const fileMenu = t.fileMenu const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) @@ -132,6 +136,9 @@ export function useStatusbarItems({ Object.values(bySession).reduce((sum, items) => sum + failedSubagentCount(items), 0) ) + // Backend truth for the free-tier chip. Refreshed on the ambient status + // cadence (use-status-snapshot), never polled from here. + const freeTier = useStore($freeTierStatus) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) const backendUpdateStatus = useStore($backendUpdateStatus) @@ -452,6 +459,23 @@ export function useStatusbarItems({ toggleLabel: copy.gateway, variant: 'menu' }, + { + detail: ( + + {freeTierCopy.signIn} + + ), + // Shown while a free-tier identity exists and the tier is on: it names the + // identity that carries the connectors (and inference when nothing else + // does), and it is the persistent way in to the sign-in. + hidden: !freeTier?.available, + icon: , + id: 'free-tier', + label: freeTierCopy.statusLabel(freeTier?.model ?? FREE_TIER_MODEL), + onSelect: () => openFreeTierSignIn(), + toggleLabel: copy.toggleFreeTier, + variant: 'action' + }, { hidden: !currentCwd, icon: , @@ -535,9 +559,12 @@ export function useStatusbarItems({ commandCenterOpen, copy, currentCwd, + freeTierCopy, fileMenu.copyPath, fileMenu.revealFileManager, fileMenu.revealInSidebar, + freeTier?.available, + freeTier?.model, gatewayMenuContent, gatewayClassName, gatewayDetail, diff --git a/apps/desktop/src/components/boot-failure-overlay.test.tsx b/apps/desktop/src/components/boot-failure-overlay.test.tsx index fedfdd2b57..dd55e83adb 100644 --- a/apps/desktop/src/components/boot-failure-overlay.test.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.test.tsx @@ -57,7 +57,8 @@ beforeEach(() => { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false }) failBoot() }) diff --git a/apps/desktop/src/components/free-tier/notice-owner.test.tsx b/apps/desktop/src/components/free-tier/notice-owner.test.tsx new file mode 100644 index 0000000000..738802e56b --- /dev/null +++ b/apps/desktop/src/components/free-tier/notice-owner.test.tsx @@ -0,0 +1,21 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway: vi.fn() }) +})) + +describe('useFreeTierNoticeOwner', () => { + it('hands the notice to a composer that is still mounted when the owner unmounts', async () => { + const { useFreeTierNoticeOwner } = await import('./notice-strip') + const first = renderHook(() => useFreeTierNoticeOwner()) + const second = renderHook(() => useFreeTierNoticeOwner()) + expect(first.result.current).toBe(true) + expect(second.result.current).toBe(false) + + act(() => first.unmount()) + + expect(second.result.current).toBe(true) + second.unmount() + }) +}) diff --git a/apps/desktop/src/components/free-tier/notice-strip.tsx b/apps/desktop/src/components/free-tier/notice-strip.tsx new file mode 100644 index 0000000000..b0f1e68e3c --- /dev/null +++ b/apps/desktop/src/components/free-tier/notice-strip.tsx @@ -0,0 +1,107 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useId } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { StatusRow } from '@/components/chat/status-row' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { + ackFreeTierNotice, + claimFreeTierNotice, + freeTierNoticeClaim, + releaseFreeTierNotice +} from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' +import { setModelPickerOpen } from '@/store/session' + +/** + * Which mounted composer gets to paint the strip. Several can be on screen at + * once (split zones, a popout mid-dock); the first to mount claims it and the + * rest report false, so one pending notice never paints N times. The CALLER + * asks, so a non-owning stack adds no empty row to its card. + */ +export function useFreeTierNoticeOwner(): boolean { + const id = useId() + const claim = useStore(freeTierNoticeClaim()) + + useEffect(() => { + claimFreeTierNotice(id) + + return () => releaseFreeTierNotice(id) + }, [id]) + + // When the owner unmounts it releases the claim; a composer still mounted takes it over, + // so the notice does not vanish until some later mount. + useEffect(() => { + if (claim === null) { + claimFreeTierNotice(id) + } + }, [claim, id]) + + return claim === id +} + +/** + * The quiet half of the free-tier introduction: shown when the user already has + * a provider of their own carrying inference, so the free models are an offer + * rather than the only road. It lives in the composer status stack — the same + * lane as the billing wall — and never blocks the composer. + * + * Backend-latched: `notice_pending` is the only source of truth, so any of the + * three actions retires it everywhere at once and no renderer flag can strand a + * strip the backend considers seen. + */ +export function FreeTierNoticeStrip() { + const { requestGateway } = useGatewayRequest() + const { t } = useI18n() + const copy = t.freeTier + + const consume = (after?: () => void) => { + void ackFreeTierNotice(requestGateway) + after?.() + } + + return ( + } + trailing={ + <> + + + + + } + trailingVisible + > + + {copy.stripTitle} + {copy.stripBody} + + + ) +} 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 new file mode 100644 index 0000000000..8918be7a85 --- /dev/null +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx @@ -0,0 +1,80 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesApi from '@/hermes' +import { $freeTierSignIn, openFreeTierSignIn } from '@/store/free-tier-sign-in' + +const pollOAuthSession = vi.fn() +const requestGateway = vi.fn(async () => ({ available: true, has_guest: true })) + +// Only the two calls this flow makes are replaced; everything else keeps its +// real implementation so the modules the dialog pulls in (the onboarding +// DeviceCode cell, the model picker) still resolve their imports. +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal()), + pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId), + startOAuthLogin: async () => ({ + expires_in: 900, + flow: 'device_code' as const, + poll_interval: 2, + session_id: 'session-1', + user_code: 'ABCD-EFGH', + verification_url: 'https://portal.example/claim?code=ABCD-EFGH' + }) +})) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +beforeEach(() => { + vi.spyOn(window, 'open').mockReturnValue(null) +}) + +afterEach(() => { + cleanup() + $freeTierSignIn.set({ status: 'closed' }) + vi.restoreAllMocks() + vi.clearAllMocks() + vi.useRealTimers() +}) + +describe('FreeTierSignInDialog', () => { + it('shows the transfer code, then the signed-in screen once the poll approves', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + pollOAuthSession.mockResolvedValue({ + account_email: 'someone@example.com', + model: 'Hermes-4-405B', + reason: null, + session_id: 'session-1', + status: 'approved' + }) + + const { FreeTierSignInDialog } = await import('./sign-in-dialog') + + await act(async () => { + render( + + + + ) + }) + + await act(async () => { + openFreeTierSignIn() + }) + + await waitFor(() => expect(screen.getByText('Do not share this code.')).toBeTruthy()) + expect(screen.getByText('Enter this code in your browser to finish signing in.')).toBeTruthy() + + // The 2s poll tick is what carries the approval through to the last screen. + await act(async () => { + await vi.advanceTimersByTimeAsync(2100) + }) + + await waitFor(() => expect(screen.getByText('Signed in as someone@example.com')).toBeTruthy()) + expect(screen.getByText('Your connectors are kept.')).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 new file mode 100644 index 0000000000..2a765e49b4 --- /dev/null +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx @@ -0,0 +1,270 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import type * as React from 'react' +import { type ReactNode, useEffect, useId } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import type { ModelSelection } from '@/app/shell/model-menu-panel' +import { DeviceCode } from '@/components/onboarding/flow' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + preventCloseButtonAutoFocus +} from '@/components/ui/dialog' +import { getGlobalModelOptions } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' +import { CheckCircle2, Loader2 } from '@/lib/icons' +import { FREE_TIER_MODEL, NOUS_PROVIDER_ID, refreshFreeTierStatus } from '@/store/free-tier' +import { + $freeTierSignIn, + beginFreeTierSignIn, + claimFreeTierSignIn, + closeFreeTierSignIn, + copyFreeTierCode, + copyFreeTierUrl, + freeTierSignInClaim, + type FreeTierSignInFailure, + releaseFreeTierSignIn +} from '@/store/free-tier-sign-in' +import { refreshOnboardingProviders } from '@/store/onboarding' +import { $currentModel, setModelPickerOpen } from '@/store/session' + +interface FreeTierSignInDialogProps { + /** The app's model-assignment path. Used only to re-home a live session that + * is still pointed at the free-tier model once a real default arrives. */ + onSelectModel?: (selection: ModelSelection) => Promise | void +} + +/** + * The one sign-in surface for the free tier. Every entry point (Settings › + * Billing, the statusbar chip, the first-launch intro) calls + * `openFreeTierSignIn()`; this host owns the gateway requester, drives the + * transfer, and paints one screen per state. + * + * Mounted once at the shell. A second mount claims nothing and renders null, so + * two hosts can never stack two dialogs over one flow. + */ +export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProps) { + const id = useId() + const claim = useStore(freeTierSignInClaim()) + const state = useStore($freeTierSignIn) + const { requestGateway } = useGatewayRequest() + const queryClient = useQueryClient() + const { t } = useI18n() + const copy = t.freeTier + const owned = claim === id + + useEffect(() => { + claimFreeTierSignIn(id) + + return () => releaseFreeTierSignIn(id) + }, [id]) + + // An entry point can only record the intent — it has no requester of its own. + // The owner picks that up and starts the real flow. + useEffect(() => { + if (owned && state.status === 'requested') { + void beginFreeTierSignIn(requestGateway) + } + }, [owned, requestGateway, state.status]) + + if (!owned || state.status === 'closed' || state.status === 'requested') { + return null + } + + // Everything that changed when the tokens landed: the account's billing, the + // model catalog (paid models are reachable now), the free-tier verdict the + // chrome paints, and the cached OAuth rows. Runs when the user leaves the + // completed screen, by either door. + const settle = (model: null | string) => { + void queryClient.invalidateQueries({ queryKey: ['billing'] }) + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + void getGlobalModelOptions({ refresh: true }).catch(() => undefined) + void refreshFreeTierStatus(requestGateway) + void refreshOnboardingProviders() + + // Only re-home a session still sitting on the free-tier model: a user who + // already picked something of their own keeps it. + if (model && $currentModel.get() === FREE_TIER_MODEL) { + void onSelectModel?.({ model, provider: NOUS_PROVIDER_ID }) + } + } + + const finish = (model: null | string, after?: () => void) => { + settle(model) + closeFreeTierSignIn() + after?.() + } + + const retry = () => void beginFreeTierSignIn(requestGateway) + + return ( + !open && closeFreeTierSignIn()} open> + + {state.status === 'setting_up' && ( + + {state.minting ? copy.settingUp : copy.waiting} + + )} + + {state.status === 'code' && ( + + void copyFreeTierCode()} /> +
+ + {state.url} + + +
+

+ {copy.doNotShare} +

+
+ {copy.waiting} + +
+
+ )} + + {state.status === 'finishing' && ( + + {copy.waiting} + + )} + + {state.status === 'completed' && ( + + {state.model && ( +
+ + {copy.defaultModel} + + + {state.model} + + +
+ )} + + + +
+ )} + + {state.status === 'already_signed_in' && ( + + + + + + )} + + {state.status === 'failed' && ( + + + + + + + )} +
+
+ ) +} + +type FreeTierCopy = Translations['freeTier'] + +function failureHeading(kind: FreeTierSignInFailure, copy: FreeTierCopy): string { + return kind === 'timed_out' ? copy.timedOutHeading : copy.didNotComplete +} + +function failureBody(kind: FreeTierSignInFailure, message: null | string, copy: FreeTierCopy): string { + switch (kind) { + case 'rejected': + return copy.rejectedBody + + case 'retired': + return copy.retiredBody + + case 'superseded': + return copy.supersededBody + + case 'timed_out': + return copy.timedOutBody + + default: + // The backend's own wording when it sent one — it names the specific + // refusal (a busy account, a transport failure) better than we can. + return message ?? copy.errorBody + } +} + +function Screen({ + body, + children, + heading, + icon +}: { + body?: string + children: ReactNode + heading: string + icon?: React.ComponentType<{ className?: string }> +}) { + return ( + <> + + {heading} + {body ? {body} : null} + + {children} + + ) +} + +function Spinner({ children }: { children: ReactNode }) { + return ( + + + {children} + + ) +} + +function Actions({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index 66026b0a55..6dce4137db 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -44,7 +44,8 @@ function resetStores() { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false }) } diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 756931bdb1..700d7bc8cc 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -496,9 +496,12 @@ function ProviderHeading({ provider }: { provider: ModelOptionProvider }) { const { t } = useI18n() const copy = t.modelPicker - // free_tier is only set for Nous. true → "Free tier", false → "Pro". + // Two different facts wear the same badge: `free_tier` is a signed-in Nous + // account on the free plan; `free_tier_row` is the no-account route's own + // row. Either way the user is on free inference, so say so. Never match the + // route by name — the label is copy. const tierBadge = - provider.free_tier === true ? ( + provider.free_tier === true || provider.free_tier_row === true ? ( {copy.freeTier} diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index dc4b16a54a..d3a8f0783d 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -146,7 +146,7 @@ function Step({ children, title }: { children: React.ReactNode; title: string }) // Device-code display: OTP-style — each character in its own readonly cell. // The whole row is the copy button (no side button, no checkmark); on copy the // cells flash emerald for feedback. Dashes render as quiet separators. -function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) { +export function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) { const { t } = useI18n() return ( diff --git a/apps/desktop/src/components/onboarding/free-tier-intro.test.ts b/apps/desktop/src/components/onboarding/free-tier-intro.test.ts new file mode 100644 index 0000000000..104bf68d81 --- /dev/null +++ b/apps/desktop/src/components/onboarding/free-tier-intro.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier' +import { $desktopOnboarding, refreshOnboarding } from '@/store/onboarding' +import type { FreeTierStatus } from '@/types/hermes' + +const READY: FreeTierStatus = { + available: true, + enabled: true, + has_guest: true, + label: 'Nous · free tier', + model: 'nous/welcome', + notice_pending: true +} + +// A configured backend whose free-tier answer the test supplies. `setup.status` +// and `setup.runtime_check` both report ready so refreshOnboarding takes the +// "already configured" path — the one the intro hangs off. +function gatewayReturning(freeTier: FreeTierStatus, route = true) { + return async (method: string): Promise => { + if (method === 'free_tier.status') { + return freeTier as T + } + + if (method === 'setup.runtime_check') { + return { free_tier: route, ok: true } as T + } + + return { provider_configured: true } as T + } +} + +afterEach(() => { + $freeTierStatus.set(null) + $freeTierRoute.set(null) +}) + +describe('free-tier introduction branch table', () => { + it('opens the ready screen when the free tier is the route inference runs on', async () => { + await refreshOnboarding({ requestGateway: gatewayReturning(READY) }) + + expect($desktopOnboarding.get().freeTierReady).toBe(true) + // The overlay owns this case, so the composer strip must stay away. + expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(false) + }) + + it('offers the composer strip instead when the user owns the provider carrying inference', async () => { + await refreshOnboarding({ requestGateway: gatewayReturning(READY, false) }) + + expect($desktopOnboarding.get().freeTierReady).toBe(false) + expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(true) + }) + + it('shows nothing once the notice has been acknowledged', async () => { + await refreshOnboarding({ requestGateway: gatewayReturning({ ...READY, notice_pending: false }) }) + + expect($desktopOnboarding.get().freeTierReady).toBe(false) + expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(false) + }) +}) + +describe('acknowledging the introduction', () => { + it('reports a failed ack so the ready screen stays up', async () => { + const { ackFreeTierIntro } = await import('@/store/onboarding') + const failing = async (method: string): Promise => { + if (method === 'free_tier.ack_notice') { + throw new Error('gateway away') + } + + return READY as T + } + + expect(await ackFreeTierIntro({ requestGateway: failing })).toBe(false) + expect(await ackFreeTierIntro({ requestGateway: gatewayReturning(READY) })).toBe(false) // status stub returns no {acked: true} + }) +}) diff --git a/apps/desktop/src/components/onboarding/index.test.tsx b/apps/desktop/src/components/onboarding/index.test.tsx index ef099f93e0..5f8b27df0e 100644 --- a/apps/desktop/src/components/onboarding/index.test.tsx +++ b/apps/desktop/src/components/onboarding/index.test.tsx @@ -17,7 +17,8 @@ function setProviders(providers: OAuthProvider[]) { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false } satisfies DesktopOnboardingState) } @@ -41,7 +42,8 @@ afterEach(() => { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false }) }) diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index d18c819753..419408c2c5 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo, useRef, useState } from 'react' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Input } from '@/components/ui/input' @@ -11,9 +12,13 @@ import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { cn } from '@/lib/utils' import { $desktopBoot, type DesktopBootState } from '@/store/boot' +import { FREE_TIER_MODEL } from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import { $localModelsEnabled } from '@/store/local-models-flag' import { $desktopOnboarding, + ackFreeTierIntro, + clearFreeTierIntro, clearPendingProviderOAuth, closeManualOnboarding, confirmOnboardingModel, @@ -25,11 +30,13 @@ import { refreshOnboarding, saveOnboardingApiKey, setOnboardingMode, + startManualOnboarding, startProviderOAuth } from '@/store/onboarding' import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' import { DocsLink, FlowPanel, Status } from './flow' +import { DecodedLabel } from './glyph' import { FeaturedProviderRow, FireworksProviderRow, @@ -231,6 +238,38 @@ export function DesktopOnboardingOverlay({ window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS) } + // The free-tier intro's three doors share one exit: consume the notice, play + // the same dissolve, then run whatever the door opens onto. `after` runs at + // the END so a sign-in dialog or provider picker never appears behind a + // still-fading overlay. + const dismissFreeTierIntro = async (after?: () => void) => { + if (leaving) { + return + } + + // The screen is keyed on the backend's notice flag: only a recorded ack takes it down. + // A failed ack leaves it in place for another try rather than hiding the only notice. + if (!(await ackFreeTierIntro(ctx))) { + return + } + + const reduce = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + + if (reduce) { + clearFreeTierIntro() + after?.() + + return + } + + setLeaving(true) + window.setTimeout(() => { + setLeaving(false) + clearFreeTierIntro() + after?.() + }, ONBOARDING_EXIT_MS) + } + useEffect(() => { if (enabled || onboarding.requested) { void refreshOnboarding(ctx) @@ -272,14 +311,14 @@ export function DesktopOnboardingOverlay({ // do we know whether to dismiss (true) or surface the picker (false). // EXCEPTION: manual mode (user opened the selector from a working app to // add/switch a provider) shows the overlay regardless of configured state. - if (onboarding.configured === true && !onboarding.manual) { + if (onboarding.configured === true && !onboarding.manual && !onboarding.freeTierReady) { return null } // The user chose "I'll choose a provider later" on first run. Stay out of the // way on every subsequent launch — they re-enter via Settings → Providers // (manual mode), which sets manual=true and bypasses this gate. - if (onboarding.firstRunSkipped && !onboarding.manual) { + if (onboarding.firstRunSkipped && !onboarding.manual && !onboarding.freeTierReady) { return null } @@ -300,11 +339,15 @@ export function DesktopOnboardingOverlay({ // In manual mode the app is already configured, so the flow is "ready" // immediately — no runtime gate needed. Otherwise wait for the readiness // check (configured === false) before showing the picker. - const ready = onboarding.manual || (enabled && onboarding.configured === false) - const showPicker = flow.status === 'idle' || flow.status === 'success' + // The free-tier intro owns the overlay while it is up: the app is already + // configured, so there is no picker to show and no runtime gate to wait on. + // A manual open (the user asked for the picker) outranks it. + const freeTierIntro = onboarding.freeTierReady && !onboarding.manual && flow.status === 'idle' + const ready = freeTierIntro || onboarding.manual || (enabled && onboarding.configured === false) + const showPicker = !freeTierIntro && (flow.status === 'idle' || flow.status === 'success') // The final "you're in" screen drops the card chrome and floats centered on // the surface — same bare, cinematic treatment as the connecting overlay. - const bare = ready && !showPicker && flow.status === 'confirming_model' + const bare = ready && (freeTierIntro || (!showPicker && flow.status === 'confirming_model')) return (
{reason ? : null} {ready ? ( - showPicker ? ( + freeTierIntro ? ( + + ) : showPicker ? ( ) : ( @@ -362,6 +407,65 @@ export function DesktopOnboardingOverlay({ ) } +/** + * The one-time free-tier welcome, shown when the free tier is what serves this + * user. Bare and centered like the model-confirm screen it stands in for: this + * IS their "you're in" moment, so it names the route, its model and its price, + * and offers the two ways out of it (a real account, or a provider of their + * own) without making either the default. + */ +function FreeTierReadyPanel({ + leaving, + onDismiss +}: { + leaving: boolean + onDismiss: (after?: () => void) => Promise +}) { + const { t } = useI18n() + const copy = t.freeTier + + return ( +
+ + +
+
+ + {t.onboarding.defaultModel} + + + {t.onboarding.freeTier} + +
+

{FREE_TIER_MODEL}

+

{copy.readyCaption}

+
+ +
+ + + +
+
+ ) +} + // The launch reason is a prompt ("why am I seeing this"), not an error. Only // rendered for meaningful caller-supplied reasons (defaults are filtered out // upstream), so it never shows the generic "no provider configured" noise. diff --git a/apps/desktop/src/components/onboarding/providers.tsx b/apps/desktop/src/components/onboarding/providers.tsx index 2612989b2e..79e6372fd3 100644 --- a/apps/desktop/src/components/onboarding/providers.tsx +++ b/apps/desktop/src/components/onboarding/providers.tsx @@ -31,7 +31,8 @@ export function FeaturedProviderRow({ provider: OAuthProvider }) { const { t } = useI18n() - const loggedIn = provider.status?.logged_in + const freeTier = provider.status?.free_tier === true + const loggedIn = provider.status?.logged_in && !freeTier return (
-

{t.onboarding.featuredPitch}

+

+ {freeTier ? t.freeTier.providerRowPitch : t.onboarding.featuredPitch} +

) } +// The free tier is an identity without an account: never "Connected", never the account's name. +function FreeTierTag() { + const { t } = useI18n() + + return ( + + {t.onboarding.freeTier} + + ) +} + function ConnectedTag() { const { t } = useI18n() @@ -119,7 +135,8 @@ export function ProviderRow({ provider: OAuthProvider }) { const { t } = useI18n() - const loggedIn = provider.status?.logged_in + const freeTier = provider.status?.free_tier === true + const loggedIn = provider.status?.logged_in && !freeTier const Trail = provider.flow === 'external' ? Terminal : ChevronRight return ( @@ -127,9 +144,9 @@ export function ProviderRow({
- {providerTitle(provider)} + {freeTier ? t.freeTier.providerRowTitle : providerTitle(provider)} - {loggedIn ? : null} + {freeTier ? : loggedIn ? : null}

{t.onboarding.flowSubtitles[provider.flow]}

diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 199ba6e163..0948fa3618 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3274,6 +3274,48 @@ export const en: Translations = { docs: provider => `${provider} docs` }, + 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.' + }, + modelPicker: { title: 'Switch model', current: 'current:', @@ -3398,6 +3440,7 @@ export const en: Translations = { toggleTerminal: 'Terminal', toggleTokensPerSecond: 'Tokens per second', toggleVersion: 'Version & updates', + toggleFreeTier: 'Free tier', toggleWorkspace: 'Workspace', cacheHitRateTitle: 'Prompt cache hit rate this session — cached tokens cost less, so higher is cheaper', tokensPerSecondTitle: 'Output tokens per second, averaged over the last 10 model calls', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 39964408f2..0ebca0ec6a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2800,6 +2800,53 @@ export interface Translations { docs: (provider: string) => string } + freeTier: { + /** Settings › Providers row title while the Nous identity is the free tier. */ + providerRowTitle: string + /** The featured row's pitch while the identity is the free tier: what signing in adds. */ + providerRowPitch: string + // First-launch introduction (ready screen + composer strip). + readyTitle: string + readyCaption: string + begin: string + signInInstead: string + otherProviders: string + stripTitle: string + stripBody: string + openModelPicker: string + dismiss: string + // Statusbar chip. + statusLabel: (model: string) => string + // Sign-in dialog. + signIn: string + signInHeading: string + settingUp: string + codeBody: string + copyLink: string + doNotShare: string + waiting: string + finishingHeading: string + finishingBody: string + signedInAs: (email: string) => string + signedIn: string + connectorsKept: string + defaultModel: string + change: string + done: string + notNow: string + tryAgain: string + startAgain: string + didNotComplete: string + rejectedBody: string + supersededBody: string + timedOutHeading: string + timedOutBody: string + retiredBody: string + errorBody: string + alreadySignedInHeading: string + alreadySignedInBody: string + } + modelPicker: { title: string current: string @@ -2924,6 +2971,7 @@ export interface Translations { toggleTerminal: string toggleTokensPerSecond: string toggleVersion: string + toggleFreeTier: string toggleWorkspace: string cacheHitRateTitle: string tokensPerSecondTitle: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f6bc67c188..ae9a80e520 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3411,6 +3411,50 @@ export const zh: Translations = { docs: provider => `${provider} 文档` }, + // 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.' + }, + modelPicker: { title: '切换模型', current: '当前:', @@ -3535,6 +3579,7 @@ export const zh: Translations = { toggleTerminal: '终端', toggleTokensPerSecond: '每秒 token 数', toggleVersion: '版本与更新', + toggleFreeTier: 'Free tier', toggleWorkspace: '工作区', cacheHitRateTitle: '本会话的提示缓存命中率 — 缓存 token 更便宜,越高越省', tokensPerSecondTitle: '每秒输出 token 数,取最近 10 次模型调用的平均值', diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts index 252bbf1963..e07c150842 100644 --- a/apps/desktop/src/lib/runtime-readiness.ts +++ b/apps/desktop/src/lib/runtime-readiness.ts @@ -4,7 +4,12 @@ export interface SetupStatusSnapshot { export interface RuntimeCheckSnapshot { error?: string + /** True when the resolved route is the free tier rather than a credential of + * the user's own. Absent on older backends. */ + free_tier?: boolean + model?: string ok?: boolean + provider?: string } export interface RuntimeReadinessSignals { @@ -22,6 +27,13 @@ export interface RuntimeReadinessOptions { export interface RuntimeReadinessResult { checksDisagree: boolean + /** Passed through from `setup.runtime_check`: the resolved route is the free + * tier. Undefined when the check did not answer (older backend, transport + * fallback) — never read it as "not free tier". */ + freeTier?: boolean + /** Passed through from `setup.runtime_check`: the model the route resolved + * to. Undefined when the check did not answer. */ + model?: string ready: boolean reason: null | string source: 'fallback' | 'runtime_check' | 'setup_status' @@ -100,12 +112,21 @@ export function interpretRuntimeReadiness( const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError) const setupFailure = normalizeMessage(signals.setupError) + // Route facts the check reported, carried through untouched so consumers + // (free-tier chrome) don't have to re-issue setup.runtime_check. Left + // undefined when the check said nothing — "absent" and "false" differ. + const route = { + freeTier: typeof signals.runtime?.free_tier === 'boolean' ? signals.runtime.free_tier : undefined, + model: normalizeMessage(signals.runtime?.model) ?? undefined + } + const checksDisagree = typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk if (typeof runtimeOk === 'boolean') { if (runtimeOk) { return { + ...route, checksDisagree, ready: true, reason: null, @@ -120,6 +141,7 @@ export function interpretRuntimeReadiness( } return { + ...route, checksDisagree, ready: false, reason, @@ -129,6 +151,7 @@ export function interpretRuntimeReadiness( if (typeof setupConfigured === 'boolean') { return { + ...route, checksDisagree: false, ready: setupConfigured, reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason), @@ -137,6 +160,7 @@ export function interpretRuntimeReadiness( } return { + ...route, checksDisagree: false, ready: unknownReady, reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason), diff --git a/apps/desktop/src/store/free-tier-sign-in.test.ts b/apps/desktop/src/store/free-tier-sign-in.test.ts new file mode 100644 index 0000000000..3bf6b369b3 --- /dev/null +++ b/apps/desktop/src/store/free-tier-sign-in.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesApi from '@/hermes' +import type { FreeTierRequester } from '@/store/free-tier' + +const startOAuthLogin = vi.fn() +const pollOAuthSession = vi.fn() +const cancelOAuthSession = vi.fn(async (_id: string) => ({ ok: true })) + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal()), + cancelOAuthSession: (id: string) => cancelOAuthSession(id), + listOAuthProviders: async () => ({ providers: [] }), + pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId), + startOAuthLogin: () => startOAuthLogin() +})) + +const requestGateway = (async (_method: string, _params?: Record): Promise => + ({ available: true, has_guest: true }) as T) satisfies FreeTierRequester +const start = (id: string) => ({ + expires_in: 900, + flow: 'device_code' as const, + poll_interval: 2, + session_id: id, + user_code: 'ABCD-EFGH', + verification_url: `https://portal.example/claim?code=${id}` +}) + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(window, 'open').mockReturnValue(null) +}) + +afterEach(async () => { + const { closeFreeTierSignIn } = await import('./free-tier-sign-in') + closeFreeTierSignIn() + vi.useRealTimers() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('free-tier sign-in attempts', () => { + it('a poll from a closed attempt never overwrites the attempt now on screen', async () => { + const { $freeTierSignIn, beginFreeTierSignIn, closeFreeTierSignIn } = await import('./free-tier-sign-in') + let resolveA: (value: unknown) => void = () => undefined + startOAuthLogin.mockResolvedValueOnce(start('session-a')).mockResolvedValueOnce(start('session-b')) + pollOAuthSession.mockImplementation( + (_provider: string, id: string) => + id === 'session-a' ? new Promise(resolve => (resolveA = resolve)) : Promise.resolve({ session_id: id, status: 'pending' }) + ) + + await beginFreeTierSignIn(requestGateway) + expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-a', status: 'code' }) + await vi.advanceTimersByTimeAsync(2000) // A's poll is now in flight + + closeFreeTierSignIn() + await beginFreeTierSignIn(requestGateway) + expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-b', status: 'code' }) + + resolveA({ account_email: 'old@example.com', model: 'x', session_id: 'session-a', status: 'approved' }) + await vi.advanceTimersByTimeAsync(10) + + expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-b', status: 'code' }) + expect(cancelOAuthSession).toHaveBeenCalledWith('session-a') + }) +}) diff --git a/apps/desktop/src/store/free-tier-sign-in.ts b/apps/desktop/src/store/free-tier-sign-in.ts new file mode 100644 index 0000000000..953466147e --- /dev/null +++ b/apps/desktop/src/store/free-tier-sign-in.ts @@ -0,0 +1,313 @@ +import { atom } from 'nanostores' + +import { cancelOAuthSession, listOAuthProviders, pollOAuthSession, startOAuthLogin } from '@/hermes' + +import { type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier' + +const POLL_MS = 2000 +const COPY_FLASH_MS = 1500 + +/** Why a sign-in ended without tokens. Each maps to one ruled screen; anything + * the backend does not name (transport failure, `account_busy`) lands on + * `error`, which carries the backend's own message when there is one. */ +export type FreeTierSignInFailure = 'error' | 'rejected' | 'retired' | 'superseded' | 'timed_out' + +export type FreeTierSignInState = + | { status: 'already_signed_in' } + | { status: 'closed' } + | { status: 'finishing' } + // A "please open the dialog" request from an entry point that has no gateway + // requester of its own. The mounted host picks it up and drives the flow. + | { status: 'requested' } + | { email: null | string; model: null | string; status: 'completed' } + | { kind: FreeTierSignInFailure; message: null | string; status: 'failed' } + // `minting` is true only when the backend still has to create the free-tier + // identity (the first `start` does it), which is the one case where the user + // waits on something worth naming. + | { minting: boolean; status: 'setting_up' } + | { + code: string + codeCopied: boolean + sessionId: string + url: string + urlCopied: boolean + status: 'code' + } + +export const $freeTierSignIn = atom({ status: 'closed' }) + +// Several surfaces can mount the dialog host (the shell, a test harness). The +// FIRST mount claims it; the rest render nothing, so one open never stacks two +// identical dialogs. Mirrors the real-profile-consent claim. +const $claim = atom(null) + +export function claimFreeTierSignIn(id: string) { + if ($claim.get() === null) { + $claim.set(id) + } +} + +export function releaseFreeTierSignIn(id: string) { + if ($claim.get() === id) { + $claim.set(null) + } +} + +export function freeTierSignInClaim() { + return $claim +} + +let pollTimer: number | null = null +let expiryTimer: number | null = null +// Each begin/close bumps the generation. A continuation that resumes after an await (a poll that was +// already in flight when the dialog closed, a start call for an abandoned attempt) compares its own +// generation and drops out, so a stale attempt never publishes over the one now on screen. +let attempt = 0 + +function clearTimers() { + if (pollTimer !== null) { + window.clearInterval(pollTimer) + pollTimer = null + } + + if (expiryTimer !== null) { + window.clearTimeout(expiryTimer) + expiryTimer = null + } +} + +const set = (state: FreeTierSignInState) => $freeTierSignIn.set(state) + +const fail = (kind: FreeTierSignInFailure, message: null | string = null) => { + clearTimers() + set({ kind, message: message?.trim() || null, status: 'failed' }) +} + +/** Every entry point calls this — Settings › Billing, the statusbar chip, the + * first-launch intro. It only records the intent; the mounted host owns the + * gateway requester and drives the flow. Re-entrant by design: a second click + * while a sign-in is already on screen must not restart it. */ +export function openFreeTierSignIn() { + if ($freeTierSignIn.get().status === 'closed') { + set({ status: 'requested' }) + } +} + +/** Close and abandon. Cancels a live device-code session so the backend is not + * left polling a window nobody is watching. */ +export function closeFreeTierSignIn() { + const state = $freeTierSignIn.get() + + attempt += 1 + clearTimers() + + if (state.status === 'code') { + cancelOAuthSession(state.sessionId).catch(() => undefined) + } + + set({ status: 'closed' }) +} + +// The reasons the backend names on a non-approved terminal poll. Anything else +// (including a bare `account_busy`) falls through to the generic error screen, +// which shows the backend's own message. +const FAILURE_BY_REASON: Record = { + account_not_anonymous: 'retired', + account_retired: 'retired', + superseded: 'superseded', + timeout: 'timed_out', + user_declined: 'rejected' +} + +// Open a sign-in URL through the desktop bridge, falling back to window.open +// when the bridge isn't there (dev preview, tests) so the flow never strands in +// a waiting state. Same contract as the onboarding store's opener. +async function openSignInUrl(url: string) { + if (window.hermesDesktop?.openExternal) { + try { + await window.hermesDesktop.openExternal(url) + + return + } catch { + // Bridge present but failed (no OS handler, user denied). Fall through. + } + } + + window.open(url, '_blank', 'noopener,noreferrer') +} + +/** + * Drive one sign-in attempt end to end: resolve what identity this Hermes is + * on, start the transfer, open the consent page, then poll until it resolves. + * Safe to call again from a "Try again" button — it clears any previous timers + * first. + */ +export async function beginFreeTierSignIn(requestGateway: FreeTierRequester) { + clearTimers() + const mine = ++attempt + const stale = () => mine !== attempt + + const status = await refreshFreeTierStatus(requestGateway) + + if (stale()) { + return + } + + const minting = !status?.has_guest + + // No free-tier identity AND a real Nous account already connected: there is + // nothing to transfer. Say so instead of minting a guest the user does not + // need. A failed provider read is not proof either way — fall through and let + // the start call be the authority. + if (minting) { + try { + const { providers } = await listOAuthProviders() + + if (stale()) { + return + } + + const nous = providers.find(provider => provider.id === NOUS_PROVIDER_ID) + + if (nous?.status.logged_in && nous.status.free_tier !== true) { + set({ status: 'already_signed_in' }) + + return + } + } catch { + // Provider list unavailable — continue with the sign-in. + } + } + + set({ minting, status: 'setting_up' }) + + try { + const start = await startOAuthLogin(NOUS_PROVIDER_ID) + + if (stale()) { + // The user closed the dialog while the start call was out: do not leave the backend + // polling a session nobody is watching. + cancelOAuthSession(start.session_id).catch(() => undefined) + + return + } + + if (start.flow !== 'device_code') { + fail('error', null) + + return + } + + await openSignInUrl(start.verification_url) + + if (stale()) { + cancelOAuthSession(start.session_id).catch(() => undefined) + + return + } + + set({ + code: start.user_code, + codeCopied: false, + sessionId: start.session_id, + status: 'code', + url: start.verification_url, + urlCopied: false + }) + + // Lapse locally when the code's own window closes, instead of polling a + // dead session forever. The backend usually flips it first and its message + // wins; this is the floor. + const ttlMs = Math.max(1, Number(start.expires_in) || 0) * 1000 + expiryTimer = window.setTimeout(() => { + expiryTimer = null + fail('timed_out', null) + }, ttlMs) + + pollTimer = window.setInterval(() => void pollOnce(start.session_id, requestGateway, mine), POLL_MS) + } catch (error) { + if (!stale()) { + fail('error', error instanceof Error ? error.message : String(error)) + } + } +} + +async function pollOnce(sessionId: string, requestGateway: FreeTierRequester, mine: number) { + const stale = () => mine !== attempt + + try { + const result = await pollOAuthSession(NOUS_PROVIDER_ID, sessionId) + + if (stale() || result.status === 'pending') { + return + } + + clearTimers() + + if (result.status !== 'approved') { + const kind = FAILURE_BY_REASON[result.reason ?? ''] ?? 'error' + fail(kind, result.error_message ?? null) + + return + } + + set({ status: 'finishing' }) + + // The tokens are on disk now, so the backend's view of this identity has + // changed: reload its env and re-read the free-tier verdict before the + // completed screen claims the user is signed in. + await requestGateway('reload.env').catch(() => undefined) + await refreshFreeTierStatus(requestGateway) + + if (stale()) { + return + } + + set({ + email: result.account_email ?? null, + model: result.model ?? null, + status: 'completed' + }) + } catch (error) { + if (!stale()) { + fail('error', error instanceof Error ? error.message : String(error)) + } + } +} + +async function copyAndFlash(text: string, field: 'codeCopied' | 'urlCopied') { + try { + await navigator.clipboard.writeText(text) + } catch { + return + } + + const current = $freeTierSignIn.get() + + if (current.status !== 'code') { + return + } + + const { sessionId } = current + set({ ...current, [field]: true }) + + window.setTimeout(() => { + const later = $freeTierSignIn.get() + + if (later.status === 'code' && later.sessionId === sessionId) { + set({ ...later, [field]: false }) + } + }, COPY_FLASH_MS) +} + +export function copyFreeTierCode() { + const state = $freeTierSignIn.get() + + return state.status === 'code' ? copyAndFlash(state.code, 'codeCopied') : Promise.resolve() +} + +export function copyFreeTierUrl() { + const state = $freeTierSignIn.get() + + return state.status === 'code' ? copyAndFlash(state.url, 'urlCopied') : Promise.resolve() +} diff --git a/apps/desktop/src/store/free-tier.ts b/apps/desktop/src/store/free-tier.ts new file mode 100644 index 0000000000..27d8f58dd9 --- /dev/null +++ b/apps/desktop/src/store/free-tier.ts @@ -0,0 +1,123 @@ +import { atom } from 'nanostores' + +import type { FreeTierStatus } from '@/types/hermes' + +/** The model the free-tier route runs on. Used to recognise a session that is + * still homed on the free tier after a sign-in. */ +export const FREE_TIER_MODEL = 'nous/welcome' + +/** The provider slug the free-tier route and a signed-in Nous account share. */ +export const NOUS_PROVIDER_ID = 'nous' + +export type FreeTierRequester = (method: string, params?: Record) => Promise + +/** + * The backend's free-tier verdict, cached for the chrome that paints it (the + * statusbar chip, the first-launch intro, the billing view). The backend is + * authoritative — this atom is only a cache of `free_tier.status`, which is a + * local, zero-network read — so nothing here ever decides on its own that the + * free tier is on or off. `null` means "not asked yet"; every consumer must + * render as if there were no free tier until an answer lands. + */ +export const $freeTierStatus = atom(null) + +function isFreeTierStatus(value: unknown): value is FreeTierStatus { + return typeof value === 'object' && value !== null && typeof (value as FreeTierStatus).has_guest === 'boolean' +} + +/** + * Pull the current status. No polling loop of its own: callers ride an existing + * cadence (the ambient status snapshot) or a seam that just changed the answer + * (boot, a completed sign-in, an acknowledged notice). + * + * A failed read leaves the last known answer in place rather than blanking the + * chrome — an older backend without the method, or a gateway flap, is not + * evidence that the free tier went away. + */ +export async function refreshFreeTierStatus(requestGateway: FreeTierRequester): Promise { + try { + const status = await requestGateway('free_tier.status') + + if (!isFreeTierStatus(status)) { + return $freeTierStatus.get() + } + + $freeTierStatus.set(status) + + return status + } catch { + return $freeTierStatus.get() + } +} + +/** Persist the one-time notice acknowledgement, then re-read so every surface + * keyed on `notice_pending` drops away together. */ +export async function ackFreeTierNotice(requestGateway: FreeTierRequester): Promise { + try { + const result = await requestGateway<{ acked?: boolean }>('free_tier.ack_notice') + + if (result?.acked !== true) { + return false + } + } catch { + // A failed ack means the notice is still owed; the caller keeps its surface up. + return false + } + + await refreshFreeTierStatus(requestGateway) + + return true +} + +/** + * Whether the SELECTED route runs on the free tier: `setup.runtime_check.free_tier`, keyed on the + * endpoint the backend resolved, not on profile state. `null` until a readiness round answers. A + * free-tier identity beside the user's own key reads `false` here while `$freeTierStatus.available` + * stays true — that split is what picks the intro's shape. + */ +export const $freeTierRoute = atom(null) + +export function setFreeTierRoute(route: boolean | null | undefined) { + $freeTierRoute.set(typeof route === 'boolean' ? route : null) +} + +/** True when the one-time introduction is still owed to this user. */ +export function freeTierNoticePending(status: FreeTierStatus | null): boolean { + return Boolean(status?.has_guest && status.notice_pending) +} + +/** The introduction is owed AND the free tier is the route: the overlay's ready screen. */ +export function freeTierReadyPending(status: FreeTierStatus | null, route: boolean | null): boolean { + return freeTierNoticePending(status) && route === true +} + +/** + * True when the introduction is owed AND a provider of the user's own carries + * inference — the case the composer strip covers. When the free tier itself + * carries inference the onboarding overlay's ready screen owns the moment + * instead, so the two can never both be on screen. + */ +export function freeTierStripPending(status: FreeTierStatus | null, route: boolean | null): boolean { + return freeTierNoticePending(status) && route === false +} + +// Several composers can be mounted at once (split zones, a popout mid-dock). +// The FIRST mounted strip claims the notice; the rest render nothing, so one +// pending notice never paints N times. Mirrors the real-profile-consent claim. +const $noticeClaim = atom(null) + +export function claimFreeTierNotice(id: string) { + if ($noticeClaim.get() === null) { + $noticeClaim.set(id) + } +} + +export function releaseFreeTierNotice(id: string) { + if ($noticeClaim.get() === id) { + $noticeClaim.set(null) + } +} + +export function freeTierNoticeClaim() { + return $noticeClaim +} diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 0f2652e748..26fad91790 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -26,6 +26,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo firstRunSkipped: false, manual: false, localEndpoint: false, + freeTierReady: false, ...overrides } } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 5813799336..d6fbce5a46 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -15,6 +15,7 @@ import { translateNow } from '@/i18n' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { setMainModelAssignment } from '@/store/cron-model-impact' +import { ackFreeTierNotice, freeTierReadyPending, refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' import { notify, notifyError } from '@/store/notifications' import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes' @@ -75,6 +76,12 @@ export interface DesktopOnboardingState { * custom endpoint"). Forces the API-key form with the local option * preselected instead of the OAuth picker. */ localEndpoint: boolean + /** True when the backend still owes this user the one-time free-tier + * introduction AND the free tier is what carries inference. It makes the + * overlay show its "Hermes is ready" screen once even though the app is + * configured. The backend's `notice_pending` flag is the only source of + * truth — there is no renderer latch — so an ack clears it everywhere. */ + freeTierReady: boolean } export interface OnboardingContext { @@ -155,7 +162,8 @@ const INITIAL: DesktopOnboardingState = { requested: false, firstRunSkipped: readCachedSkipped(), manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false } export const $desktopOnboarding = atom(INITIAL) @@ -434,6 +442,14 @@ function providerResolutionFailure(reason: null | string) { : 'Connected, but Hermes still cannot resolve a usable provider.' } +/** Re-read the OAuth provider list into the onboarding cache. Exported so a + * flow that changes a provider's auth state outside onboarding (a free-tier + * sign-in) can keep the cached rows honest instead of leaving the picker + * describing the previous identity. */ +export async function refreshOnboardingProviders() { + await refreshProviders() +} + async function refreshProviders() { if (providersRefreshPromise) { await providersRefreshPromise @@ -519,6 +535,8 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB providers: null, requested: true, localEndpoint: false, + // The picker replaces the free-tier ready screen when the user asked for it. + freeTierReady: false, // `null` opts out of the prompt banner entirely (e.g. when the user already // picked a specific provider and we auto-start its sign-in). reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null, @@ -581,7 +599,7 @@ export function closeManualOnboarding() { providersRefreshPromise = null pendingProviderOAuthId = null - patch({ targetProfile: undefined, manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } }) + patch({ targetProfile: undefined, manual: false, requested: false, localEndpoint: false, freeTierReady: false, flow: { status: 'idle' } }) } export function completeDesktopOnboarding() { @@ -599,7 +617,8 @@ export function completeDesktopOnboarding() { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + freeTierReady: false }) } @@ -612,7 +631,14 @@ export function completeDesktopOnboarding() { export function dismissFirstRunOnboarding() { clearPoll() writeCachedSkipped(true) - patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } }) + patch({ + firstRunSkipped: true, + requested: false, + manual: false, + localEndpoint: false, + freeTierReady: false, + flow: { status: 'idle' } + }) } export function setOnboardingMode(mode: OnboardingMode) { @@ -634,6 +660,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) { if (runtime.ready) { completeDesktopOnboarding() + await applyFreeTierIntro(ctx, runtime) ctx.onCompleted?.() return true @@ -671,6 +698,40 @@ export async function refreshOnboarding(ctx: OnboardingContext) { return false } +/** + * Ask the backend whether the one-time free-tier introduction is still owed, + * and if so which shape it takes. Pull-based on purpose: the flag lives on the + * identity, so a second window (or a reinstall against the same home) shows the + * intro exactly once between them. + * + * The runtime check's route flag picks the shape. When the free tier is the + * route inference runs on, the overlay stays up on a ready screen — this is + * their first launch and they have nothing else. When a provider of their own + * carries inference, the overlay is not warranted: the composer strip (keyed on + * the same notice flag) offers the free models without interrupting. + */ +async function applyFreeTierIntro(ctx: OnboardingContext, runtime: RuntimeReadinessResult) { + setFreeTierRoute(runtime.freeTier) + const status = await refreshFreeTierStatus(ctx.requestGateway) + + if (freeTierReadyPending(status, runtime.freeTier ?? null)) { + patch({ freeTierReady: true }) + } +} + +/** "Begin" / "Sign in instead" / "Other providers" all consume the notice — the + * user has seen it. Returns whether the backend recorded it: on a failed write + * the ready screen stays up, because the flag it is keyed on is still pending. */ +export async function ackFreeTierIntro(ctx: OnboardingContext): Promise { + return ackFreeTierNotice(ctx.requestGateway) +} + +/** Take the ready screen down. Separate from the ack because the overlay plays + * its exit BEFORE unmounting — clearing the flag up front would cut the fade. */ +export function clearFreeTierIntro() { + patch({ freeTierReady: false }) +} + // Open a sign-in URL via the desktop bridge, falling back to window.open // when the bridge isn't present (e.g. the web dashboard / dev preview) so // the flow never silently stalls in a waiting state. Mirrors the pattern in diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4e5c140a57..bcabdaa5df 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -56,8 +56,15 @@ export interface ElevenLabsVoicesResponse { } export interface OAuthProviderStatus { + /** Nous only: the tier name the token resolves to, when the backend knows + * one. Null for a free-tier identity and for older backends. */ + account_tier?: null | string error?: string expires_at?: null | string + /** Nous only: true when the stored token belongs to a free-tier identity + * rather than a signed-in account. `logged_in` stays true either way — a + * token exists — so this is the only way to tell the two apart. */ + free_tier?: boolean has_refresh_token?: boolean last_refresh?: null | string logged_in: boolean @@ -107,12 +114,41 @@ export interface OAuthSubmitResponse { } export interface OAuthPollResponse { + /** Approved sign-ins only: the account the tokens now belong to. Null when + * the backend has no address for it. */ + account_email?: null | string error_message?: null | string expires_at?: null | number + /** Approved sign-ins only: the default model the backend settled on. Null + * when the config already pointed at the user's own model and was left + * alone. */ + model?: null | string + /** Why a non-approved terminal status ended that way: `user_declined` + * (status `denied`), or `superseded` / `account_retired` / + * `account_not_anonymous` / `account_busy` / `timeout` (status `error`). + * `error_message` carries the matching user-facing text. */ + reason?: null | string session_id: string status: 'approved' | 'denied' | 'error' | 'expired' | 'pending' } +/** Result of the `free_tier.status` RPC. Pull-only: it reads local auth state + * and makes no network call, so it is safe to refresh on the ambient status + * cadence. */ +export interface FreeTierStatus { + /** An identity exists AND the free tier is on: connectors ride on it, and so + * does inference when nothing else carries it. Whether inference actually + * runs on it is the ROUTE's answer (`setup.runtime_check.free_tier`). */ + available: boolean + enabled: boolean + has_guest: boolean + /** Display name for the route, e.g. "Nous · free tier". */ + label: string + model: string + /** True until the one-time introduction has been acknowledged. */ + notice_pending: boolean +} + export interface MemoryProviderOAuthStatus { auth: 'apikey' | 'oauth' | null connected: boolean @@ -461,8 +497,13 @@ export interface ModelOptionProvider { /** Per-model pricing keyed by model id (present when the picker requested * pricing and the provider supports live pricing). */ pricing?: Record - /** Nous only: whether the current account is on the free tier. */ + /** Nous only: whether the current account is on the free plan. Set by + * pricing for a signed-in account — NOT the same thing as `free_tier_row`, + * which marks the no-account route. */ free_tier?: boolean + /** True for the free-tier route's own provider row (no account behind it). + * Never match this row by `name` — the label is copy and can change. */ + free_tier_row?: boolean /** Nous only: paid models a free-tier user cannot select (shown disabled). */ unavailable_models?: string[] /** Per-model option support, keyed by model id (present when the picker diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index eefb2f853c..2d4f80047f 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -596,6 +596,10 @@ def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cac models = row.get("models") or [] if not models: continue + if row.get("free_tier_row"): + # The free tier's one model has no Portal pricing and no entitlement to read: pricing + # it would lock the only row a free-tier install can select. + continue try: pricing_kwargs = {"cached_only": True} if cached_only else {} raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {} diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 8a307a738d..f8d5ee641d 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -455,6 +455,10 @@ def _free_tier_nous_row(row: dict) -> dict | None: out["name"] = anon_auth.FREE_TIER_LABEL out["models"] = [anon_auth.GUEST_MODEL] out["total_models"] = 1 + # The explicit flag every consumer keys on (pricing, badges): never the display name. It also + # tells the picker this is the free tier's identity, distinct from ``free_tier`` (an account on + # the free plan) which pricing sets from the Portal's entitlement read. + out["free_tier_row"] = True return out diff --git a/hermes_cli/web_routers/oauth.py b/hermes_cli/web_routers/oauth.py index fd50f4bcb2..1a13e84fd1 100644 --- a/hermes_cli/web_routers/oauth.py +++ b/hermes_cli/web_routers/oauth.py @@ -272,10 +272,10 @@ def _status_card( # refresh. xai: source_label is a human-readable origin (auth-store path / # credential source), not the internal auth_mode string ("oauth_pkce"). _PROVIDER_STATUS: Dict[str, tuple[str, Callable[[dict], dict]]] = { - "nous": ("get_nous_auth_status_local", lambda r: _status_card( + "nous": ("get_nous_auth_status_local", lambda r: {**_status_card( r, "nous_portal", r.get("portal_base_url") or "Nous Portal", _truncate_token(r.get("access_token")), r.get("access_expires_at"), bool(r.get("has_refresh_token")), - )), + ), "free_tier": bool(r.get("free_tier")), "account_tier": r.get("account_tier")}), "openai-codex": ("get_codex_auth_status", lambda r: _status_card( r, r.get("source") or "openai_codex", r.get("auth_mode") or "OpenAI Codex", _truncate_token(r.get("api_key")), None, False, r.get("last_refresh"), @@ -324,24 +324,50 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: async def _start_nous_device_code(profile: Optional[str]) -> Dict[str, Any]: + """Start a Nous sign-in. Over a free-tier identity (``nous.guest`` on) the same start also registers + the connector transfer with the account service, so the browser leg is that transfer's consent + page and its code, and the poller waits for the transfer before the token grant. Without one it is + the plain device-code flow.""" + from hermes_cli import anon_auth from hermes_cli.auth import PROVIDER_REGISTRY, _request_device_code + from hermes_cli.web_server_profiles import _profile_scope pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url ).rstrip("/") - device_data = await _httpx_call(lambda client: _request_device_code( - client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope, - )) + with _profile_scope(_oauth_profile_name(profile)): + guest = anon_auth.current_nous_state() if anon_auth.guest_enabled() else None + anon_token = str(guest.get("anon_token") or "") if anon_auth.is_guest_state(guest) else "" + + def _start(client): + device = _request_device_code( + client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope) + if not anon_token: + return device, None + intent = anon_auth.register_promotion_intent( + client, portal_base_url, anon_token, user_code=str(device["user_code"]), + device_code=str(device["device_code"])) + return device, intent + + device_data, intent = await _httpx_call(_start) + user_code = str(device_data["user_code"]) + verification_url = str(device_data["verification_uri_complete"]) + expires_in, interval = int(device_data["expires_in"]), int(device_data["interval"]) + fields = dict( + device_code=str(device_data["device_code"]), portal_base_url=portal_base_url, + client_id=pconfig.client_id, scope=pconfig.scope) + if intent is not None: + claim_url = str(intent.get("claim_url") or "") + if claim_url.startswith("/"): + claim_url = f"{portal_base_url}{claim_url}" + user_code = str(intent["claim_code"]) + verification_url = claim_url or verification_url + expires_in = min(expires_in, int(intent.get("expires_in") or expires_in)) + interval = int(intent.get("interval") or interval) + fields["claim_code"] = user_code + fields.update(interval=interval, expires_at=time.time() + expires_in) return _device_session_started( - "nous", profile, _nous_poller, - dict( - device_code=str(device_data["device_code"]), interval=int(device_data["interval"]), - expires_at=time.time() + int(device_data["expires_in"]), portal_base_url=portal_base_url, - client_id=pconfig.client_id, scope=pconfig.scope, - ), - str(device_data["user_code"]), str(device_data["verification_uri_complete"]), - int(device_data["expires_in"]), int(device_data["interval"]), - ) + "nous", profile, _nous_poller, fields, user_code, verification_url, expires_in, interval) async def _start_codex_device_code(profile: Optional[str]) -> Dict[str, Any]: @@ -640,6 +666,9 @@ async def poll_oauth_session(provider_id: str, session_id: str, profile: Optiona return { "session_id": session_id, "status": sess["status"], "error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"), + # Nous over a free-tier identity: why a transfer ended, who signed in, and the default model + # the completion settled on (None when the config was on the user's own model). + "reason": sess.get("reason"), "account_email": sess.get("account_email"), "model": sess.get("model"), } diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py index 3d565dc5b4..7b5b9bd26d 100644 --- a/hermes_cli/web_routers/status.py +++ b/hermes_cli/web_routers/status.py @@ -655,6 +655,9 @@ def _get_portal_status_sync(): "logged_in": bool(auth.get("logged_in")), "portal_url": auth.get("portal_base_url"), "inference_url": auth.get("inference_base_url"), "provider": str((model_cfg or {}).get("provider") or ""), + # Free tier: a token exists, so logged_in stays true for callers that only ask "is there a + # credential"; surfaces that render an account must branch on free_tier first. + "free_tier": bool(auth.get("free_tier")), "account_tier": auth.get("account_tier"), "subscription_url": "https://portal.nousresearch.com/manage-subscription", "features": features} diff --git a/hermes_cli/web_server_oauth.py b/hermes_cli/web_server_oauth.py index 077f3c1da9..947f9c6c99 100644 --- a/hermes_cli/web_server_oauth.py +++ b/hermes_cli/web_server_oauth.py @@ -198,8 +198,15 @@ def _oauth_poller(label: str): try: fn(session_id, sess) with _oauth_sessions_lock: - sess["status"] = "approved" - _log.info("oauth/device: %s login completed (session=%s)", label, session_id) + # A body that already settled the session (a sign-in the user declined in the + # browser is ``denied`` with a ``reason``) keeps its verdict. + settled = sess["status"] != "pending" + if not settled: + sess["status"] = "approved" + if settled: + _log.info("oauth/device: %s login ended %s (session=%s)", label, sess["status"], session_id) + else: + _log.info("oauth/device: %s login completed (session=%s)", label, session_id) except Exception as e: _log.warning("%s device-code poll failed (session=%s): %s", label, session_id, e) with _oauth_sessions_lock: @@ -209,19 +216,71 @@ def _oauth_poller(label: str): return deco +def _settle_promotion_failure(sess: Dict[str, Any], outcome: Dict[str, Any]) -> None: + """Record a transfer that did not complete on the session: ``denied`` when the user rejected it in + the browser, else ``error``; ``reason`` and the ruled copy ride along for the renderer.""" + from hermes_cli import anon_auth + status = str(outcome.get("status") or "unknown") + reason = "timeout" if status == "timeout" else str(outcome.get("reason") or status) + message = (anon_auth.UPGRADE_TIMED_OUT if reason == "timeout" + else anon_auth.UPGRADE_REASON_COPY.get(reason, anon_auth.UPGRADE_NOT_COMPLETED)) + with _oauth_sessions_lock: + sess["status"] = "denied" if reason == "user_declined" else "error" + sess["reason"] = reason + sess["error_message"] = message + if reason in anon_auth._RETIRED_REASONS: + anon_auth.clear_dead_guest("retired") + + @_oauth_poller("nous") def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None: - """Background poller that drives a Nous device-code flow to completion.""" + """Background poller that drives a Nous device-code flow to completion. + + A session started over a free-tier identity carries ``claim_code``: the transfer of that identity's + connectors into the account is watched first (``wait_for_promotion``), and only a completed + transfer is followed by the token grant, so an install never loses its connectors to a sign-in the + user did not confirm. Every completion then runs ``settle_after_upgrade`` so a config still on the + free tier's route moves to the account's host and model; ``account_email`` and ``model`` land on + the session for the poll response. + """ from hermes_cli.web_server_profiles import _profile_scope from hermes_cli.auth import _poll_for_token, persist_nous_credentials, refresh_nous_oauth_from_state + from hermes_cli import anon_auth import httpx portal_base_url, client_id = sess["portal_base_url"], sess["client_id"] + claim_code = str(sess.get("claim_code") or "") + outcome: Dict[str, Any] = {} + + def _cancelled() -> bool: + # The user abandoned this sign-in (DELETE /sessions/{id}) while this thread was blocked + # on the portal: nothing it learns afterwards may reach the auth store. + with _oauth_sessions_lock: + if sess.get("cancelled"): + sess["status"] = "cancelled" + return True + return False + with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: + expires_in = max(60, int(sess["expires_at"] - time.time())) + if claim_code: + try: + outcome = anon_auth.wait_for_promotion( + client, portal_base_url, claim_code, expires_in=expires_in, interval=int(sess["interval"])) + except anon_auth.AnonCredentialDead: + outcome = {"status": "voided", "reason": "account_retired"} + if _cancelled(): + return + if str(outcome.get("status")) != "completed": + with _profile_scope(_oauth_session_profile(session_id)): + _settle_promotion_failure(sess, outcome) + return token_data = _poll_for_token( client=client, portal_base_url=portal_base_url, client_id=client_id, - device_code=sess["device_code"], expires_in=max(60, int(sess["expires_at"] - time.time())), + device_code=sess["device_code"], expires_in=expires_in, poll_interval=sess["interval"], ) + if _cancelled(): + return # Same post-processing as _nous_device_code_login (validate/refresh JWT) now = datetime.now(timezone.utc) token_ttl = int(token_data.get("expires_in") or 0) @@ -242,7 +301,19 @@ def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None: } with _profile_scope(_oauth_session_profile(session_id)): full_state = refresh_nous_oauth_from_state(auth_state, timeout_seconds=15.0, force_refresh=False) - persist_nous_credentials(full_state) + if claim_code: + full_state["auth_method"] = anon_auth.UPGRADED_AUTH_METHOD + # The final cancellation check and the save share the session lock, so a cancel cannot + # land between them; the settle step (which may contact the portal) runs after the lock. + with _oauth_sessions_lock: + if sess.get("cancelled"): + sess["status"] = "cancelled" + return + persist_nous_credentials(full_state) + settled = anon_auth.settle_after_upgrade(full_state) + with _oauth_sessions_lock: + sess["account_email"] = str(outcome.get("account_email") or "") or None + sess["model"] = settled.get("model") or None @_oauth_poller("minimax") diff --git a/tests/hermes_cli/test_anon_desktop_signin.py b/tests/hermes_cli/test_anon_desktop_signin.py new file mode 100644 index 0000000000..8364b130eb --- /dev/null +++ b/tests/hermes_cli/test_anon_desktop_signin.py @@ -0,0 +1,108 @@ +"""Desktop (dashboard API) sign-in over a Nous free-tier identity. + +``POST /api/providers/oauth/nous/start`` registers the connector transfer with the account service +and hands the renderer the transfer's code and consent URL; the poller waits for the transfer, then +takes the token grant, persists the account, and settles the default model. Driven through the real +FastAPI routes against a fake account service (the same fake ``hermes auth upgrade`` is tested with). +""" + +from __future__ import annotations + +import time + +import httpx +import pytest +from fastapi.testclient import TestClient + +from hermes_cli import anon_auth +from hermes_cli.auth import _load_auth_store +from hermes_cli.web_server import _SESSION_TOKEN, app +from tests.hermes_cli.test_anon_upgrade import ( + EMAIL, FREE_PICK, INFERENCE, PORTAL, WELCOME, _model_config, _write_model_config, free_account, portal) + +client = TestClient(app) +HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} + +__all__ = ["free_account", "portal"] # fixtures imported from the CLI test module + + +def _wait_for_terminal(session_id: str, timeout: float = 10.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + body = client.get(f"/api/providers/oauth/nous/poll/{session_id}", headers=HEADERS).json() + if body["status"] != "pending": + return body + time.sleep(0.05) + pytest.fail("poller never left pending") + + +def test_start_registers_the_transfer_and_completion_settles_the_account(portal, free_account): + anon_auth.ensure_portal_identity(blocking=True) + _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) + + resp = client.post("/api/providers/oauth/nous/start", headers=HEADERS) + assert resp.status_code == 200, resp.text + start = resp.json() + # The renderer shows the transfer's code and consent page, not the generic device page. + assert start["user_code"] == "clm_1" + assert start["verification_url"] == f"{PORTAL}/device" + assert portal.intent_bodies[0]["user_code"] == portal.user_code + assert portal.intent_bodies[0]["device_code"] == portal.device_code + + body = _wait_for_terminal(start["session_id"]) + assert body["status"] == "approved" + assert body["account_email"] == EMAIL + assert body["model"] == FREE_PICK + state = _load_auth_store()["providers"]["nous"] + assert "anon_token" not in state and not anon_auth.is_guest_state(state) + model_cfg = _model_config() + assert model_cfg["default"] == FREE_PICK + assert model_cfg["base_url"] == INFERENCE.rstrip("/") + assert not anon_auth.route_is_welcome_host(model_cfg["base_url"]) + + +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) + portal.status_sequence = [{"status": "voided", "reason": "user_declined"}] + + start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json() + body = _wait_for_terminal(start["session_id"]) + assert body["status"] == "denied" + assert body["reason"] == "user_declined" + assert body["error_message"] == anon_auth.UPGRADE_REASON_COPY["user_declined"] + assert portal.token_grants == 0 + assert _load_auth_store()["providers"]["nous"]["anon_token"] == guest["anon_token"] + + +def test_status_routes_report_the_free_tier(portal): + anon_auth.ensure_portal_identity(blocking=True) + portal_status = client.get("/api/portal", headers=HEADERS).json() + assert portal_status["free_tier"] is True + assert portal_status["account_tier"] == "anonymous" + providers = client.get("/api/providers/oauth", headers=HEADERS).json()["providers"] + nous = next(p for p in providers if p["id"] == "nous") + assert nous["status"]["free_tier"] is True + + +def test_a_sign_in_cancelled_while_waiting_never_persists_the_account(portal, free_account, monkeypatch): + """The poller is blocked on the transfer when the user cancels; when the wait returns completed, + nothing may reach the auth store.""" + import threading + from hermes_cli import web_server_oauth + guest = anon_auth.ensure_portal_identity(blocking=True) + release = threading.Event() + + def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval): + release.wait(10) + return {"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL} + monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait_until_released) + + start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json() + assert client.delete(f"/api/providers/oauth/sessions/{start['session_id']}", headers=HEADERS).json()["ok"] is True + release.set() + for _ in range(100): + time.sleep(0.05) + assert portal.token_grants == 0 + state = _load_auth_store()["providers"]["nous"] + assert state["anon_token"] == guest["anon_token"] and anon_auth.is_guest_state(state) + assert web_server_oauth._oauth_sessions.get(start["session_id"]) is None diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py index c902be5510..ee7f0803f8 100644 --- a/tests/hermes_cli/test_anon_picker.py +++ b/tests/hermes_cli/test_anon_picker.py @@ -89,3 +89,18 @@ def test_guest_identity_with_guest_off_hides_the_nous_row(guest_home, monkeypatc from hermes_cli.model_switch_providers import list_picker_providers assert _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) == [] assert _cli_nous_rows({"nous": {"guest": False}}) == [] + + +def test_desktop_picker_payload_never_locks_the_free_tier_row(guest_home, monkeypatch): + """``model.options`` (the desktop's path) prices rows from caches on a normal open; the free-tier + row has no Portal pricing and no entitlement to read, so it must be left alone rather than locked.""" + from hermes_cli import inventory + monkeypatch.setattr(inventory, "_prewarm_pricing_async", lambda *a, **k: None) + payload = inventory.build_model_options_payload(inventory.load_picker_context()) + rows = _nous_rows(payload["providers"]) + assert len(rows) == 1 + row = rows[0] + assert row["free_tier_row"] is True + assert row["models"] == ["nous/welcome"] + assert row.get("unavailable_models", []) == [] + assert not row.get("free_tier_pending") and not row.get("pricing_pending") diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py new file mode 100644 index 0000000000..116e900268 --- /dev/null +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -0,0 +1,85 @@ +"""``free_tier.status`` / ``free_tier.ack_notice`` (pull) and the free-tier branch of ``billing.state``, +driven through the registered RPC handlers against a seeded free-tier identity.""" + +from __future__ import annotations + +import base64 +import json +import time + +import pytest + +import tui_gateway.server as srv +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _call(method: str, params: dict | None = None) -> dict: + return srv._methods[method](1, params or {})["result"] + + +@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) + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = { + "auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001", + "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1"} + store["active_provider"] = "nous" + _save_auth_store(store) + + +def _set_guest_off(monkeypatch): + from hermes_cli import config as cfg_mod + monkeypatch.setattr(anon_auth, "guest_enabled", lambda: False) + return cfg_mod + + +def test_status_is_pull_from_local_state_and_ack_persists_on_the_identity(guest, monkeypatch): + status = _call("free_tier.status") + assert status == {"has_guest": True, "enabled": True, "available": True, "notice_pending": True, + "model": "nous/welcome", "label": anon_auth.FREE_TIER_LABEL} + + assert _call("free_tier.ack_notice") == {"acked": True} + assert _call("free_tier.status")["notice_pending"] is False + assert _load_auth_store()["providers"]["nous"][anon_auth.GUEST_NOTICE_FLAG] is True + + # nous.guest: false -> the identity exists but carries nothing; no notice either. + _set_guest_off(monkeypatch) + status = _call("free_tier.status") + assert status["has_guest"] is True and status["enabled"] is False + assert status["available"] is False and status["notice_pending"] is False + + +def test_billing_state_answers_the_free_tier_locally(guest, monkeypatch): + import agent.billing_view as bv + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: pytest.fail("free tier must not call the portal")) + res = _call("billing.state") + assert res["ok"] is True and res["logged_in"] is False + assert res["free_tier"] is True and res["free_tier_model"] == "nous/welcome" + assert res["usage"] == {"available": False} + + _set_guest_off(monkeypatch) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: bv.BillingState(logged_in=False)) + res = _call("billing.state") + 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): + 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) + status = _call("free_tier.status") + assert status["has_guest"] is False and status["available"] is False + assert calls == [{"blocking": False}] diff --git a/tui_gateway/billing_view.py b/tui_gateway/billing_view.py index 5759f57ac9..48631bb7b9 100644 --- a/tui_gateway/billing_view.py +++ b/tui_gateway/billing_view.py @@ -71,9 +71,12 @@ def _serialize_auto_reload(ar, format_money) -> dict | None: "reload_to_display": format_money(ar.reload_to_usd), "card": card_out} -def _serialize_billing_state(state) -> dict: - """Serialize a BillingState for the wire (Decimals → strings, money-safe).""" +def _serialize_billing_state(state, *, free_tier: bool = False) -> dict: + """Serialize a BillingState for the wire (Decimals → strings, money-safe). ``free_tier`` marks the + Nous free tier: no account, no balance, nothing to pay; the renderer branches on it before + ``logged_in``.""" from agent.billing_view import format_money + from hermes_cli.anon_auth import GUEST_MODEL card = mc = None if state.card is not None: @@ -88,7 +91,9 @@ def _serialize_billing_state(state) -> dict: "spent_display": format_money(m.spent_this_month_usd), "is_default_ceiling": m.is_default_ceiling} return { - "ok": True, "logged_in": state.logged_in, "org_name": state.org_name, + "ok": True, "logged_in": state.logged_in, + "free_tier": bool(free_tier), "free_tier_model": GUEST_MODEL if free_tier else None, + "org_name": state.org_name, "org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin, "can_change_plan": state.can_change_plan, "can_charge": state.can_charge, "balance_usd": _wire_str(state.balance_usd), diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index b60562e1a8..9458478448 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -305,8 +305,13 @@ def _(rid, params: dict) -> dict: if not (callable(api_key) or api_key_text in {"aws-sdk", "no-key-required"} or has_usable_secret(api_key_text) or bool(runtime.get("command"))): return fail(f"No usable credentials found for {provider}.", runtime.get("source")) + from hermes_cli.anon_auth import route_is_welcome_host + # free_tier is keyed on the SELECTED route (the welcome host serves only nous/welcome), not + # on profile state: a paid Nous key beside a free-tier identity must not read as free. return {"ok": True, "provider": runtime.get("provider"), "model": runtime.get("model"), - "source": runtime.get("source"), **scoped} + "source": runtime.get("source"), + "free_tier": provider == "nous" and route_is_welcome_host(runtime.get("base_url")), + **scoped} return _readiness_check(rid, params, probe) except Exception as e: return _ok(rid, {"ok": False, "error": str(e)}) diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py new file mode 100644 index 0000000000..3cf66efb9c --- /dev/null +++ b/tui_gateway/methods_free_tier.py @@ -0,0 +1,59 @@ +"""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`` +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. +""" + +import logging + +from .method_ctx import HandlerRegistry, bind_module + +logger = logging.getLogger(__name__) +_registry = HandlerRegistry() +method = _registry.method +_profile_scoped = _registry.profile_scoped + + +@method("free_tier.status") +@_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.""" + 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()), + "model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL}) + except Exception as e: + return _err(rid, 5090, str(e)) + + +@method("free_tier.ack_notice") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Mark the availability notice shown on the free-tier identity. ``acked`` is false when there is + no free-tier identity to mark (nothing to show again either).""" + try: + from hermes_cli import anon_auth + return _ok(rid, {"acked": bool(anon_auth.mark_guest_notice_shown())}) + except Exception as e: + return _err(rid, 5091, str(e)) + + +def register(server) -> None: + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index c9d8d6eaa7..a6f38cd4ec 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -1541,8 +1541,21 @@ def _billing_view(name: str, module: str, builder: str, serializer: str, fallbac return _ok(rid, dict(fallback)) -_billing_view("billing.state", "agent.billing_view", "build_billing_state", "_serialize_billing_state", - {"ok": True, "logged_in": False, "error": "could not load billing state"}) +@method("billing.state") +def _(rid, params: dict) -> dict: + """Read-only billing view (no scope required); fail-open. The Nous free tier has no account to + bill, so its state is answered locally (``free_tier`` set, ``logged_in`` false) without a portal + round-trip that could only fail.""" + try: + from agent.billing_view import BillingState, build_billing_state + from hermes_cli.anon_auth import guest_carries_inference + if guest_carries_inference(): + return _ok(rid, _serialize_billing_state(BillingState(logged_in=False), free_tier=True)) + return _ok(rid, _serialize_billing_state(build_billing_state())) + except Exception: + return _ok(rid, {"ok": True, "logged_in": False, "free_tier": False, "error": "could not load billing state"}) + + _billing_view("usage.bars", "agent.billing_usage", "build_usage_model", "_serialize_usage_model", # two-bar $ view {"ok": True, "available": False}) _billing_view("subscription.state", "agent.subscription_view", "build_subscription_state", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0abc099681..064e2784c6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3218,7 +3218,7 @@ from . import ( # noqa: E402 methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view, methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign, methods_session_control as _methods_session_control, methods_subagents as _methods_subagents, - methods_vault as _methods_vault) + methods_vault as _methods_vault, methods_free_tier as _methods_free_tier) for _m in ( _session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch, @@ -3228,6 +3228,6 @@ for _m in ( _methods_browser_control, _methods_session, _methods_prompt, _methods_config, _methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images, _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign, - _methods_session_control, _methods_subagents, _methods_vault): + _methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier): _m.register(sys.modules[__name__]) del _m diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index 17f0c71231..a39a5dc19c 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -91,6 +91,29 @@ does not carry your connectors over. Use `hermes auth upgrade` when you have con to keep. ::: +## On Hermes Desktop + +The desktop app runs on the same free tier as the CLI and shows it in four places: + +| Where | What you see | +|---|---| +| First launch | A ready screen: "Hermes is ready." with the default model `nous/welcome`, a Free tier badge, and **Begin**. "Sign in with a Nous account instead" and "Other providers" sit under it. The screen shows once. | +| First launch with your own API key already present | A one-time strip above the composer: "Free Nous inference and connectors are now available." with **Open model picker**, **Sign in** and **Dismiss**. | +| Status bar | A chip "Nous · free tier · nous/welcome" with a **Sign in** badge while the free tier carries inference. You can hide it from the bar's right-click menu. | +| Settings › Billing | "You're on the Nous free tier" with one **Sign in** button; the summary reads Plan "Free tier", Model `nous/welcome`, Connectors "Included". There is no balance and nothing to pay, so no payment or usage sections appear. | + +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 +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 +picker. + +The desktop reads all of this from the same local state the CLI writes. The ready screen and the +strip are keyed on the same one-time flag the CLI notice uses, so seeing one on the CLI means you +will not see it again on the desktop for that free-tier identity, and the other way round. + ## Turning the free tier off ```bash