3b01b4ce0f
* feat(desktop): free-tier state over RPC, status routes that name it, and a sign-in that keeps connectors The desktop learns about the Nous free tier by reading local auth state (pull): free_tier.status answers has_guest / enabled / carries_inference / notice_pending with zero network, and free_tier.ack_notice persists the one-time notice flag on the identity itself. setup.runtime_check reports free_tier for the selected route; /api/portal, the Nous card in /api/providers/oauth and billing.state carry free_tier (billing answers the free tier locally instead of a portal call that can only fail). The free-tier picker row carries an explicit free_tier_row flag and is never priced or locked. POST /api/providers/oauth/nous/start over a free-tier identity registers the connector transfer and returns its code and consent URL; the poller waits for the transfer before the token grant, persists the account, runs settle_after_upgrade, and the poll response gains reason, account_email and model. * feat(desktop): free tier on Hermes Desktop: ready screen, notice strip, status chip, Billing view, one sign-in dialog The renderer reads the free tier from free_tier.status (pull) into one store; the first-launch intro is the same state rendered two ways, keyed on the backend's one-time flag: the onboarding overlay opens on a ready screen when the free tier carries inference, else a one-time strip above the composer. Settings > Billing gains a free_tier view (notice with one Sign in, Plan / Model / Connectors summary, plan card, footnote; no payment or usage rows). A status-bar chip names the tier and model while it carries inference. Every entry point opens one claimed sign-in dialog that drives the extended oauth/nous route and maps the poll's status and reason to the ruled screens; Done settles billing, model options, providers and re-homes a session still on nous/welcome. The picker badge also fires on free_tier_row. Docs: Desktop section in the free-tier guide, AGENTS notes. * fix(desktop): free_tier.status starts the free tier's background setup when no identity exists A served backend has no session-setup moment like the CLI's, so beside an explicit provider the free tier was never set up on the desktop: no connectors, no notice strip. The first status read now starts the same one-attempt background setup; the call itself never waits. * fix(desktop): one Sign in on the Billing page; Settings > Providers names the free tier, never Connected The free-tier plan card is the what-you-get text alone (the notice carries the page's one Sign in). The Nous provider row reads Nous · free tier with a Free tier tag while the identity is the free tier, instead of Nous Portal · Connected. * fix(desktop): Settings > Providers never files the free tier under Connected * fix(desktop): the intro's shape is keyed on the route, not on the identity free_tier.status reports available (an identity exists and the tier is on); whether inference runs on the free tier is setup.runtime_check.free_tier, keyed on the resolved endpoint. The ready screen shows when that route is the free tier; the composer strip when the user's own provider carries inference. An own-key install used to get the ready screen. * docs(desktop): say what the free-tier chip is keyed on * fix(desktop): the featured Nous row's pitch on the free tier says what signing in adds * fix(desktop): a cancelled or superseded sign-in attempt can no longer change the identity or hide the intro Four lifecycle holes from review. The Nous poller checks the session's cancelled flag after the transfer wait, after the token grant, and once more under the session lock together with the save, so a sign-in the user abandoned never persists. The renderer's sign-in store carries an attempt generation that every continuation checks after each await, so a poll from a closed attempt cannot publish over the one on screen (and its backend session is cancelled). The ready screen comes down only after the backend recorded the acknowledgement. A composer still mounted takes over the notice claim when its owner unmounts. One thin test per hole.
86 lines
3.7 KiB
Python
86 lines
3.7 KiB
Python
"""``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}]
|