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