From fb178648df1143fefdb14d9ab8acbd4e5b096d2b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 9 Jun 2026 23:54:10 -0700 Subject: [PATCH 1/5] [eric] free-trial: backend dispatch injection, hardware fingerprint, mint/clear, exhaustion upsell --- backend/apps/agents/agent_manager.py | 48 +++-- backend/apps/agents/core/error_classify.py | 16 ++ backend/apps/agents/providers/registry.py | 14 +- backend/apps/nine_router/sync_custom.py | 16 +- backend/apps/settings/credentials.py | 40 ++++- backend/apps/settings/models.py | 7 + backend/apps/settings/settings.py | 15 +- backend/apps/subscription/free_trial.py | 193 +++++++++++++++++++++ backend/apps/subscription/router.py | 20 +++ backend/tests/test_free_trial.py | 57 ++++++ 10 files changed, 388 insertions(+), 38 deletions(-) create mode 100644 backend/apps/subscription/free_trial.py create mode 100644 backend/tests/test_free_trial.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 955ef29f..d5f51cce 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -30,6 +30,7 @@ from backend.apps.agents.core.error_classify import ( _NON_TRANSIENT_PATTERNS, _TRANSIENT_CAPACITY_PATTERNS, _is_auth_error, + _is_free_trial_exhausted, _is_long_context_error, _is_transient_capacity_error, ) @@ -1252,7 +1253,7 @@ class AgentManager: and _primary_is_claude and anthropic_web_search_is_reliable( uses_direct_anthropic_api=_uses_direct_anthropic_api, - is_pro=(getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro"), + is_pro=(getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial")), ) ) @@ -1592,19 +1593,21 @@ class AgentManager: env["ENABLE_TOOL_SEARCH"] = "auto" options_kwargs["env"] = env logger.info(f"[MCP-DEBUG] Using OpenRouter for {session.model}") - elif api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro": - proxy_url = getattr(global_settings, "openswarm_proxy_url", None) or "https://api.openswarm.com" - bearer = getattr(global_settings, "openswarm_bearer_token", "") or "" + elif api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"): + from backend.apps.settings.credentials import proxy_auth + bearer, proxy_url = proxy_auth(global_settings) + bearer = bearer or "" options_kwargs["env"] = { "ANTHROPIC_AUTH_TOKEN": bearer, "ANTHROPIC_BASE_URL": proxy_url, # Pin subagent ids; CLI default 'claude-haiku-4-5-20251001' # gets rejected by Pro's surface as "No credentials for provider: anthropic". + # (Free-trial down-routes all of these to Haiku server-side.) "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", } - logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}") + logger.info(f"[MCP-DEBUG] Using OpenSwarm cloud proxy at {proxy_url}") elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key: options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} logger.info("[MCP-DEBUG] Using direct Anthropic API key") @@ -2700,12 +2703,12 @@ class AgentManager: # showing the SDK's Anthropic-rate # estimate, which is meaningless here. _free_route = True - if ( - api_type == "anthropic" - and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro" - and getattr(global_settings, "openswarm_bearer_token", None) - ): - _free_route = True + if api_type == "anthropic": + from backend.apps.settings.credentials import proxy_auth as _proxy_auth + _pa_tok, _ = _proxy_auth(global_settings) + # Pro and free-trial both run server-funded, so per-token cost to the user is 0. + if _pa_tok: + _free_route = True if _free_route: cost = 0.0 @@ -2943,6 +2946,29 @@ class AgentManager: }) except Exception: logger.debug("submit_diagnostic for context_overflow failed", exc_info=True) + elif _is_free_trial_exhausted(e, extra_text=_stderr_tail): + # Free runs spent. Flip back to own_key and show a friendly + # "connect a model" upsell instead of a raw 402. + try: + from backend.apps.subscription.free_trial import clear_free_trial + await clear_free_trial(load_settings()) + except Exception: + logger.debug("clear_free_trial after exhaustion failed", exc_info=True) + friendly_msg = ( + "You've used your free runs. Connect a model to keep going: " + "your own API key, an AI subscription you already pay for, or " + "OpenSwarm Pro." + ) + error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id) + session.messages.append(error_msg) + await ws_manager.send_to_session(session_id, "agent:free_trial_exhausted", { + "session_id": session_id, + "message": friendly_msg, + }) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": error_msg.model_dump(mode="json"), + }) elif _is_auth_error(e, extra_text=_stderr_tail): # Three sub-cases the user can hit, with distinct fixes: # 1. "No credentials for provider: claude", user picked a diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 9add3ec3..58bf8c09 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -31,6 +31,7 @@ _NON_TRANSIENT_PATTERNS = re.compile( r"|missing\s+bearer\s+token" r"|extra\s+usage\s+is\s+required\s+for\s+long\s+context" r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))" + r"|free_trial_exhausted|used\s+your\s+free" r"|401|403)", re.IGNORECASE, ) @@ -53,6 +54,21 @@ def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool: )) +def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: + """True when the cloud says the machine's free runs are spent (a 402 with + type free_trial_exhausted). The catch-all path uses this to flip back to + own_key and show a friendly connect-a-model upsell instead of a raw error. + """ + combined = f"{exc!s}\n{extra_text}".strip() + if not combined: + return False + return bool(re.search( + r"free_trial_exhausted|used\s+your\s+free\s+(?:openswarm\s+)?runs", + combined, + re.IGNORECASE, + )) + + def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream error is a 401/403 auth failure. diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index f3d968e0..27f0c535 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -248,7 +248,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: if entry.get("route") == "openrouter": return entry.get("router_model_id", short_name) if entry.get("api") == "anthropic": - if getattr(settings, "connection_mode", "own_key") == "openswarm-pro": + # openswarm-pro AND free-trial both proxy-route, so resolve to the bare + # id (the proxy serves it) instead of the cc/-prefixed id that 401s when + # no Claude subscription is connected. This is the line that otherwise + # turns a free-trial user's first run into "No AI provider connected". + if getattr(settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"): return entry.get("model_id", short_name) if getattr(settings, "anthropic_api_key", None): return entry.get("model_id", short_name) @@ -337,9 +341,11 @@ async def resolve_aux_model( if "openrouter" in connected: return (or_aux, base_url) - if getattr(settings, "connection_mode", "own_key") == "openswarm-pro": - proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com" - return (bare, proxy_url) + if getattr(settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"): + from backend.apps.settings.credentials import proxy_auth + token, base = proxy_auth(settings) + if token: + return (bare, base) if getattr(settings, "anthropic_api_key", None): return (bare, None) diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py index d09fa22a..7696270c 100644 --- a/backend/apps/nine_router/sync_custom.py +++ b/backend/apps/nine_router/sync_custom.py @@ -326,17 +326,17 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str async def sync_pro_routing(settings_obj) -> None: - """Mirror the settings' pro-mode state into the 9Router Claude lane. - Call after any flow that changes connection_mode or the bearer - (activate, sign-in, sign-out, disconnect). Never raises.""" + """Mirror the settings' cloud-proxy state (openswarm-pro OR free-trial) into + the 9Router Claude lane. Call after any flow that changes connection_mode or + the bearer (activate, sign-in, sign-out, disconnect, free-trial arm/clear). + Never raises.""" try: - pro = getattr(settings_obj, "connection_mode", None) == "openswarm-pro" - bearer = getattr(settings_obj, "openswarm_bearer_token", None) - proxy = getattr(settings_obj, "openswarm_proxy_url", None) or "https://api.openswarm.com" - active = bool(pro and bearer) + from backend.apps.settings.credentials import proxy_auth + bearer, base = proxy_auth(settings_obj) + active = bool(bearer) await sync_openswarm_pro_as_claude( bearer if active else None, - proxy if active else None, + base if active else None, ) except Exception as e: logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}") diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py index ceb7221b..75779f6c 100644 --- a/backend/apps/settings/credentials.py +++ b/backend/apps/settings/credentials.py @@ -10,6 +10,26 @@ if TYPE_CHECKING: OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com" +# Connection modes that route Claude traffic through our cloud proxy with a +# bearer instead of a user-held key. Free-trial is openswarm-pro's cheaper +# sibling: same proxy, but pointed at the /free sub-path the cloud meters and +# forces to Haiku. +PROXY_CONNECTION_MODES = ("openswarm-pro", "free-trial") + + +def proxy_auth(settings: AppSettings) -> tuple[str | None, str | None]: + """(auth_token, base_url) for whichever cloud-proxy mode is active, else + (None, None). Consumers append /v1/messages to base_url as usual; for + free-trial the base carries the /free segment so the same SDK lands on the + metered route.""" + mode = getattr(settings, "connection_mode", "own_key") + base = (getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/") + if mode == "openswarm-pro": + return (getattr(settings, "openswarm_bearer_token", None), base) + if mode == "free-trial": + return (getattr(settings, "free_trial_token", None), base + "/free") + return (None, None) + def _check_9router() -> bool: """Check if 9Router is running locally.""" @@ -34,8 +54,9 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> return if p == "anthropic": - if getattr(settings, "connection_mode", "own_key") == "openswarm-pro": - if not getattr(settings, "openswarm_bearer_token", None): + if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES: + token, _ = proxy_auth(settings) + if not token: raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.") return if settings.anthropic_api_key: @@ -72,10 +93,11 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, validate_credentials(settings, provider) if p in ("anthropic", "claude"): - if getattr(settings, "connection_mode", "own_key") == "openswarm-pro": + if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES: + token, base = proxy_auth(settings) return { - "auth_token": getattr(settings, "openswarm_bearer_token", "") or "", - "base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL, + "auth_token": token or "", + "base_url": base or OPENSWARM_DEFAULT_PROXY_URL, } return {"api_key": settings.anthropic_api_key or ""} @@ -101,11 +123,11 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic: """Return an AsyncAnthropic client for the user's current connection mode.""" import anthropic - if getattr(settings, "connection_mode", "own_key") == "openswarm-pro": - proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL + if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES: + token, base = proxy_auth(settings) return anthropic.AsyncAnthropic( - auth_token=getattr(settings, "openswarm_bearer_token", None), - base_url=proxy_url, + auth_token=token, + base_url=base or OPENSWARM_DEFAULT_PROXY_URL, ) # Prefer the user's own API key when present. diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 2667ddb6..65eaebc3 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -69,6 +69,13 @@ class AppSettings(BaseModel): connection_mode: str = "own_key" openswarm_bearer_token: Optional[str] = None openswarm_proxy_url: Optional[str] = None + # Zero-config free trial: server-funded runs for a brand-new user with no + # key and no subscription. connection_mode flips to "free-trial" while armed; + # the token + remaining count are server-owned (minted by the cloud, sticky + # per machine). remaining is cached for the onboarding "runs low" nudge. + free_trial_token: Optional[str] = None + free_trial_remaining: Optional[int] = None + free_trial_runs_limit: Optional[int] = None openswarm_subscription_plan: Optional[str] = None openswarm_subscription_expires: Optional[str] = None openswarm_usage_cached: Optional[dict] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index c3c38547..09d99c64 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -45,7 +45,7 @@ async def settings_lifespan(): getattr(s, "google_api_key", None), getattr(s, "openai_api_key", None), getattr(s, "openrouter_api_key", None), - getattr(s, "connection_mode", None) == "openswarm-pro", + getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"), bool(getattr(s, "custom_providers", None) or []), ]) if needs_router: @@ -59,11 +59,11 @@ async def settings_lifespan(): await sync_openai_api_key(s.openai_api_key) if getattr(s, "openrouter_api_key", None): await sync_openrouter_api_key(s.openrouter_api_key) - if getattr(s, "connection_mode", None) == "openswarm-pro": - bearer = getattr(s, "openswarm_bearer_token", None) - proxy = getattr(s, "openswarm_proxy_url", None) or "https://api.openswarm.com" + if getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"): + from backend.apps.settings.credentials import proxy_auth + bearer, base = proxy_auth(s) if bearer: - await sync_openswarm_pro_as_claude(bearer, proxy) + await sync_openswarm_pro_as_claude(bearer, base) await sync_custom_providers(getattr(s, "custom_providers", None) or []) _asyncio.create_task(_boot_router_then_sync()) @@ -123,6 +123,9 @@ SERVER_OWNED_FIELDS = ( "openswarm_subscription_plan", "openswarm_subscription_expires", "openswarm_usage_cached", + "free_trial_token", + "free_trial_remaining", + "free_trial_runs_limit", "user_id", "signin_method", "installation_id", @@ -142,7 +145,7 @@ async def update_settings(body: AppSettings): secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", - "openswarm_bearer_token", "installation_id"} + "openswarm_bearer_token", "free_trial_token", "installation_id"} safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys} _sync(safe) diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py new file mode 100644 index 00000000..b3273611 --- /dev/null +++ b/backend/apps/subscription/free_trial.py @@ -0,0 +1,193 @@ +"""Zero-config free trial: arm/refresh/clear the server-funded free runs. + +A brand-new user with no key and no subscription gets a small number of agent +runs funded through the cloud's shared pool, so they see the product work before +being asked to connect anything. Identity is a hashed hardware fingerprint +computed here in the backend (no Electron IPC), so deleting/reinstalling the app +does not reset the count. The cloud is authoritative for the run count and the +forced cheap model; this module only mirrors state into settings and 9Router. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import platform +import re +import subprocess + +import httpx + +from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL +from backend.apps.settings.settings import save_settings_async + +logger = logging.getLogger(__name__) + +# Namespaces the hash so a raw hardware UUID never leaves the device. Public on +# purpose (open-source): it only prevents transmitting the raw id, not a secret. +_FP_SALT = "openswarm-free-trial-v1" + + +def _enabled() -> bool: + return os.environ.get("OPENSWARM_FREE_TRIAL_ENABLED", "1") != "0" + + +def _raw_hardware_id() -> str | None: + """A stable per-machine id that survives app reinstall / data wipe.""" + system = platform.system() + try: + if system == "Darwin": + out = subprocess.run( + ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], + capture_output=True, text=True, timeout=3, + ).stdout + m = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out) + return m.group(1) if m else None + if system == "Windows": + import winreg # type: ignore + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography" + ) as key: + val, _ = winreg.QueryValueEx(key, "MachineGuid") + return val or None + for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"): + if os.path.exists(path): + with open(path, "r") as f: + v = f.read().strip() + if v: + return v + except Exception: + return None + return None + + +def _fingerprint(settings_obj) -> str | None: + raw = _raw_hardware_id() + if not raw: + # Fail-soft: installation_id is less durable (regenerates on wipe) but + # better than nothing on a machine where the hardware id can't be read. + raw = getattr(settings_obj, "installation_id", None) + if not raw: + return None + return hashlib.sha256((_FP_SALT + raw).encode("utf-8")).hexdigest() + + +def _has_own_model(s) -> bool: + """True if the user already has any real model path; never shadow it.""" + if any(getattr(s, k, None) for k in ( + "anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", + "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", + )): + return True + if getattr(s, "connection_mode", "own_key") == "openswarm-pro" and getattr(s, "openswarm_bearer_token", None): + return True + for cp in (getattr(s, "custom_providers", None) or []): + name = cp.get("name") if isinstance(cp, dict) else getattr(cp, "name", None) + base = cp.get("base_url") if isinstance(cp, dict) else getattr(cp, "base_url", None) + if (name or "").strip() and (base or "").strip(): + return True + return False + + +def _proxy_base(settings_obj) -> str: + return (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/") + + +async def _sync_routing(settings_obj) -> None: + try: + from backend.apps.nine_router import sync_pro_routing + await sync_pro_routing(settings_obj) + except Exception as e: + logger.debug("free-trial routing sync skipped: %s", e) + + +async def clear_free_trial(settings_obj) -> None: + """Drop the trial token and revert to own_key. Keeps free_trial_remaining + (so the UI knows it's spent) and never touches a real paid mode.""" + if getattr(settings_obj, "connection_mode", "own_key") == "free-trial": + settings_obj.connection_mode = "own_key" + settings_obj.free_trial_token = None + await save_settings_async(settings_obj) + await _sync_routing(settings_obj) + + +async def arm_free_trial(settings_obj) -> dict: + """Mint (or re-fetch) the machine's grant and, if runs remain, flip into + free-trial mode. Guarded: never arms over a real key/subscription.""" + if not _enabled(): + return {"armed": False, "reason": "disabled"} + if getattr(settings_obj, "connection_mode", "own_key") not in ("own_key", "free-trial"): + return {"armed": False, "reason": "other_mode"} + if _has_own_model(settings_obj): + return {"armed": False, "reason": "has_model"} + + fp = _fingerprint(settings_obj) + if not fp: + return {"armed": False, "reason": "no_fingerprint"} + + base = _proxy_base(settings_obj) + payload: dict = {"fingerprint_hash": fp} + if getattr(settings_obj, "installation_id", None): + payload["install_id"] = settings_obj.installation_id + if getattr(settings_obj, "user_id", None): + payload["user_id"] = settings_obj.user_id + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.post(f"{base}/api/free-trial/mint", json=payload) + except httpx.HTTPError as e: + logger.debug("free-trial mint network error: %s", e) + return {"armed": False, "reason": "network"} + if r.status_code != 200: + return {"armed": False, "reason": "upstream", "code": r.status_code} + + data = r.json() + remaining = int(data.get("runs_remaining") or 0) + settings_obj.free_trial_remaining = remaining + settings_obj.free_trial_runs_limit = int(data.get("runs_limit") or 0) or None + + if remaining > 0: + settings_obj.connection_mode = "free-trial" + settings_obj.free_trial_token = data.get("trial_token") + settings_obj.openswarm_proxy_url = base + await save_settings_async(settings_obj) + await _sync_routing(settings_obj) + return {"armed": True, "runs_remaining": remaining, "runs_limit": settings_obj.free_trial_runs_limit} + + # Already spent on this machine: record it but don't arm. + await clear_free_trial(settings_obj) + return {"armed": False, "reason": "exhausted", "runs_remaining": 0} + + +async def refresh_free_trial(settings_obj) -> dict: + """Re-read remaining runs from the cloud. Called after a session ends so the + onboarding 'runs low' nudge stays honest. Clears the trial when spent.""" + token = getattr(settings_obj, "free_trial_token", None) + if getattr(settings_obj, "connection_mode", "own_key") != "free-trial" or not token: + return {"connected": False, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)} + + base = _proxy_base(settings_obj) + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.post( + f"{base}/api/free-trial/status", + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError: + return {"connected": True, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)} + + if r.status_code == 401: + await clear_free_trial(settings_obj) + return {"connected": False, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)} + if r.status_code != 200: + return {"connected": True, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)} + + data = r.json() + remaining = int(data.get("runs_remaining") or 0) + settings_obj.free_trial_remaining = remaining + if remaining <= 0: + await clear_free_trial(settings_obj) + return {"connected": False, "runs_remaining": 0} + await save_settings_async(settings_obj) + return {"connected": True, "runs_remaining": remaining, "runs_limit": getattr(settings_obj, "free_trial_runs_limit", None)} diff --git a/backend/apps/subscription/router.py b/backend/apps/subscription/router.py index 8354caf2..f4162381 100644 --- a/backend/apps/subscription/router.py +++ b/backend/apps/subscription/router.py @@ -343,6 +343,26 @@ async def portal(): return {"url": data.get("url")} +# --------------------------------------------------------------------------- +# Free zero-config trial +# --------------------------------------------------------------------------- + +@subscription.router.post("/free-trial/mint") +async def free_trial_mint(): + """Renderer calls this on launch when no model is connected. Mints (or + re-fetches) the machine's grant and arms free-trial mode if runs remain. + Best-effort: never raises, returns {armed: false, reason} on any miss.""" + from backend.apps.subscription.free_trial import arm_free_trial + return await arm_free_trial(load_settings()) + + +@subscription.router.post("/free-trial/status") +async def free_trial_status(): + """Refresh remaining free runs from the cloud (drives the onboarding nudge).""" + from backend.apps.subscription.free_trial import refresh_free_trial + return await refresh_free_trial(load_settings()) + + # --------------------------------------------------------------------------- # POST /api/subscription/disconnect # --------------------------------------------------------------------------- diff --git a/backend/tests/test_free_trial.py b/backend/tests/test_free_trial.py new file mode 100644 index 00000000..ac61df64 --- /dev/null +++ b/backend/tests/test_free_trial.py @@ -0,0 +1,57 @@ +"""Free-trial dispatch injection: the pure-logic pieces that decide routing.""" + +import backend # noqa: F401 (path sanity asserted below) + +from backend.apps.settings.models import AppSettings +from backend.apps.settings.credentials import proxy_auth +from backend.apps.agents.core.error_classify import ( + _is_free_trial_exhausted, + _is_transient_capacity_error, +) +from backend.apps.agents.providers.registry import resolve_model_id_for_sdk +from backend.apps.subscription.free_trial import _has_own_model + + +def test_proxy_auth_for_each_mode(): + assert proxy_auth(AppSettings()) == (None, None) + + pro = AppSettings( + connection_mode="openswarm-pro", + openswarm_bearer_token="bear", + openswarm_proxy_url="https://api.openswarm.com", + ) + assert proxy_auth(pro) == ("bear", "https://api.openswarm.com") + + free = AppSettings( + connection_mode="free-trial", + free_trial_token="ftk", + openswarm_proxy_url="https://api.openswarm.com", + ) + # Free-trial carries the /free segment so the same SDK lands on the metered route. + assert proxy_auth(free) == ("ftk", "https://api.openswarm.com/free") + + +def test_free_trial_resolves_to_a_bare_anthropic_id(): + s = AppSettings(connection_mode="free-trial", free_trial_token="ftk") + mid = resolve_model_id_for_sdk("sonnet", s) + # The bug this fixes: without the free-trial branch this returns a cc/-prefixed + # id that 401s when no Claude subscription is connected. + assert "cc/" not in mid + assert mid.startswith("claude-") + + +def test_exhaustion_is_classified_and_not_retried(): + assert _is_free_trial_exhausted(Exception("error type free_trial_exhausted")) + assert _is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs")) + assert not _is_free_trial_exhausted(Exception("overloaded, try again")) + # Must NOT look transient, or the agent loop would retry a spent trial forever. + assert not _is_transient_capacity_error(Exception("free_trial_exhausted")) + + +def test_has_own_model_never_shadows_a_real_provider(): + assert not _has_own_model(AppSettings(connection_mode="free-trial", free_trial_token="x")) + assert not _has_own_model(AppSettings()) + assert _has_own_model(AppSettings(anthropic_api_key="sk-ant-x")) + assert _has_own_model( + AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="b") + ) From c8c95e429797366142aacae0d998cbc5e54f8e27 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 10 Jun 2026 00:01:37 -0700 Subject: [PATCH 2/5] [eric] free-trial: value-first onboarding reorder, arm-on-launch, exhaustion upsell card --- frontend/src/app/Main.tsx | 11 ++++++++++- .../src/app/components/Onboarding/steps/index.ts | 5 ++++- .../components/Onboarding/steps/skipPredicates.ts | 14 ++++++++++++++ .../Onboarding/steps/step01_connectModel.ts | 13 ++++++++----- .../Onboarding/steps/step02_enableActions.ts | 7 ++++--- .../Onboarding/steps/step03_launchAgent.ts | 4 +++- .../Onboarding/steps/step04_useBrowser.ts | 2 +- .../app/pages/AgentChat/bubbles/MessageBubble.tsx | 10 ++++++++++ frontend/src/shared/state/settingsSlice.ts | 6 +++++- 9 files changed, 59 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 34a29123..295be880 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -232,7 +232,16 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = .then((r) => { if (r.ok) dispatch(fetchSettings()); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + // Arm the zero-config free trial when nothing is connected so a brand-new + // user can run an agent immediately. The backend no-ops if a real key or + // subscription exists, so this is safe to fire on every launch. + fetch(`${API_BASE}/subscription/free-trial/mint`, { method: 'POST' }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data && data.armed) dispatch(fetchSettings()); }) + .catch(() => {}); + }); }, [dispatch]); useEffect(() => { diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 1ff3ce2e..2c66a1e0 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -8,10 +8,13 @@ import { step06 } from './step06_agentControlAgents'; import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; +// Value-first order: launch an agent (step03) FIRST so a brand-new user sees +// the product work on the free trial, then connect-your-own-model (step01). +// Everything else is "learn the features", revealed after the first win. export const STEPS: OnboardingStep[] = [ + step03, step01, step02, - step03, step04, step05, step06, diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index 913c7873..b4907e4a 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -26,6 +26,20 @@ export function hasModelConnected(s: RootState): boolean { return false; } +/** True while the server-funded free trial is armed (no key needed yet). */ +export function hasFreeTrialActive(s: RootState): boolean { + const d = s.settings.data as any; + return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token); +} + +/** True when free runs are running out (or already armed-and-spent). Surfaces the connect-model step. */ +export function freeRunsLow(s: RootState): boolean { + const d = s.settings.data as any; + if (!d) return false; + const remaining = d.free_trial_remaining; + return typeof remaining === 'number' && remaining <= 2; +} + export function hasAnyToolEnabled(s: RootState): boolean { const items = s.tools?.items ?? {}; // Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on. diff --git a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts index 346b4233..f5cdcebc 100644 --- a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts +++ b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts @@ -1,16 +1,19 @@ import type { OnboardingStep } from './types'; import { S } from '../selectors'; -import { hasModelConnected } from './skipPredicates'; +import { hasModelConnected, hasFreeTrialActive, freeRunsLow } from './skipPredicates'; export const step01: OnboardingStep = { id: 'connect_model', stage: 'get_started', - index: 1, - title: 'Connect an AI model', - description: 'This is the brain behind your agents.', + // Moved to last in "Get started": the user only meets this after they've + // seen value, framed as "keep going". Stays suppressed while the free trial + // is armed and runs aren't low; un-suppresses when they're about to run out. + index: 2, + title: 'Keep going: connect your model', + description: 'Your free runs are limited. Add your own model to keep building.', videoSrc: './onboarding-videos/v2/01.mp4', videoDurationLabel: '0:24', - skipIf: hasModelConnected, + skipIf: (s) => hasModelConnected(s) || (hasFreeTrialActive(s) && !freeRunsLow(s)), ops: [ { kind: 'move_to', target: S.sidebarSettingsButton }, { kind: 'popup', text: 'Pop into Settings.' }, diff --git a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts index 67a6bda9..2cf2897b 100644 --- a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts +++ b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts @@ -4,13 +4,14 @@ import { isYoutubeEnabled } from './skipPredicates'; export const step02: OnboardingStep = { id: 'enable_actions', - stage: 'get_started', - index: 2, + // Demoted out of the first-run path: a feature to discover after the first win. + stage: 'learn_features', + index: 3, title: 'Enable agentic actions', description: 'Allow agents to work across your apps.', videoSrc: './onboarding-videos/v2/02.mp4', videoDurationLabel: '0:24', - // Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on. + // Narrowed to YouTube so users with other tools still get walked. skipIf: isYoutubeEnabled, ops: [ { kind: 'move_to', target: S.sidebarActions }, diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts index d1b0429e..26bee206 100644 --- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts +++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts @@ -11,7 +11,9 @@ const FALLBACK_PROMPT = export const step03: OnboardingStep = { id: 'launch_agent', stage: 'get_started', - index: 3, + // Value first: this is now step 1. With the free trial armed it runs with no + // model connected and no sign-in, so the user sees an agent work immediately. + index: 1, title: 'Launch your first Agent', description: 'Click the chat bubble to fire up a new Agent in a dashboard.', videoSrc: './onboarding-videos/v2/03.mp4', diff --git a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts index 40c6f389..20debf01 100644 --- a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts @@ -4,7 +4,7 @@ import { hasAnyBrowserSpawned } from './skipPredicates'; export const step04: OnboardingStep = { id: 'use_browser', - stage: 'get_started', + stage: 'learn_features', index: 4, title: 'Use the built-in browser', description: diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index b9cb6f03..5eac5b2a 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -101,6 +101,16 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro ctaAction: 'upgrade', }; } + if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) { + return { + kind: 'cap', + title: "You've used your free runs", + detail: + 'Connect a model to keep going: your own API key, an AI subscription you already pay for, or OpenSwarm Pro.', + ctaLabel: 'Connect a model', + ctaAction: 'settings', + }; + } if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) { return { kind: 'network', diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 70f8e6e5..503df792 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -57,9 +57,13 @@ export interface AppSettings { dev_mode: boolean; allow_experimental_updates: boolean; /** Managed subscription state; surfaces only when user has subscribed via cloud. */ - connection_mode?: 'own_key' | 'openswarm-pro'; + connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial'; openswarm_bearer_token?: string | null; openswarm_proxy_url?: string | null; + /** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */ + free_trial_token?: string | null; + free_trial_remaining?: number | null; + free_trial_runs_limit?: number | null; openswarm_subscription_plan?: string | null; openswarm_subscription_expires?: string | null; openswarm_usage_cached?: SubscriptionUsage | null; From 714e1e6d067722876fa51e26e93a91027f73a549 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 10 Jun 2026 00:04:45 -0700 Subject: [PATCH 3/5] [eric] free-trial: one-click starter-prompt chips on the empty dashboard --- .../Dashboard/canvas/DashboardCanvas.tsx | 2 +- .../Dashboard/canvas/DashboardEmptyState.tsx | 76 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index bb1c52de..62fb717a 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -215,7 +215,7 @@ const DashboardCanvas: React.FC = ({ /> {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? ( - + ) : (
= ({ c }) => { +// Broad, one-click-complete prompts so a brand-new user gets the "it works" +// moment without thinking up a task. Each runs to a useful result on its own. +const STARTER_PROMPTS = [ + 'Find the latest AI news and give me a short summary', + 'Research the top 3 standing desks and compare them', + 'Explain how RAG works like I\'m five', + 'Write a short poem about the sea', +]; + +const DashboardEmptyState: React.FC<{ + c: ClaudeTokens; + onLaunch?: (prompt: string, mode: string, model: string) => void; +}> = ({ c, onLaunch }) => { // The host hides Dashboard with visibility:hidden (not display:none), which keeps // CSS animations ticking; gate on active so the shimmer only burns while watched. const active = useDashboardActive(); + const model = useAppSelector((s) => s.settings.data.default_model); + const mode = useAppSelector((s) => s.settings.data.default_mode); + const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); + const [launching, setLaunching] = React.useState(false); + + // Only offer chips once a run can actually succeed (free trial armed or a real + // model connected); otherwise fall back to the plain hint. + const showChips = !!onLaunch && canRun; + + const launch = (prompt: string) => { + if (launching || !onLaunch) return; + setLaunching(true); // empty state unmounts on first session, but guard a fast double-click + onLaunch(prompt, mode, model); + }; + return ( = ({ c }) => { below to launch your first agent + + {showChips && ( + + + or try one of these + + {STARTER_PROMPTS.map((prompt) => ( + launch(prompt)} + disabled={launching} + sx={{ + px: 1.4, + py: 0.8, + borderRadius: 2, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.82rem', + cursor: launching ? 'default' : 'pointer', + opacity: launching ? 0.5 : 1, + fontFamily: 'inherit', + transition: 'background 150ms ease-in-out, border-color 150ms ease-in-out', + '&:hover': launching ? {} : { background: c.bg.elevated, borderColor: c.border.strong }, + }} + > + {prompt} + + ))} + + )} ); }; From d242114643a0fde3a7889d339f3b81a7dc3690f8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 10 Jun 2026 03:35:14 -0700 Subject: [PATCH 4/5] [eric] onboarding: per-step soft unlocks, locked steps show a teaser, spotlight skips locked --- .../components/Onboarding/OnboardingPanel.tsx | 20 ++++++- .../Onboarding/OnboardingRoadmapModal.tsx | 58 ++++++++++--------- .../components/Onboarding/steps/stepUnlock.ts | 45 ++++++++++++++ 3 files changed, 93 insertions(+), 30 deletions(-) create mode 100644 frontend/src/app/components/Onboarding/steps/stepUnlock.ts diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index d5661a8b..9d01abac 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -14,6 +14,7 @@ import { useAppDispatch } from '@/shared/hooks'; import { useOnboardingProgress } from './hooks/useOnboardingProgress'; import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice'; import { STEPS, findStepById } from './steps'; +import { useUnlockedStepIds } from './steps/stepUnlock'; import { STAGE_LABELS } from './steps/types'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; @@ -59,13 +60,26 @@ const OnboardingPanel: React.FC = () => { // Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls. const lastShowMeClickRef = useRef(0); + const unlockedIds = useUnlockedStepIds(); const currentStep = useMemo(() => { + // Spotlight only lands on an unlocked, not-yet-done step, so we never tell + // the user to "Show me" something they haven't unlocked yet. const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null; - if (explicit && !progress.completedSteps.includes(explicit.id)) return explicit; - return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null; - }, [progress.currentStepId, progress.completedSteps]); + if ( + explicit && + !progress.completedSteps.includes(explicit.id) && + unlockedIds.has(explicit.id) + ) { + return explicit; + } + return ( + STEPS.find( + (s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id), + ) ?? null + ); + }, [progress.currentStepId, progress.completedSteps, unlockedIds]); // Stage name labels the panel; the count + bar stay global so progress never resets between stages. const stageOf = currentStep?.stage ?? 'get_started'; diff --git a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx index 372fbd22..ad4022fa 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx @@ -10,6 +10,7 @@ import CloseIcon from '@mui/icons-material/Close'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useOnboardingProgress } from './hooks/useOnboardingProgress'; import { STAGE_GROUPS, STEPS, findStepById } from './steps'; +import { useUnlockedStepIds, unlockHintFor } from './steps/stepUnlock'; import { STAGE_LABELS } from './steps/types'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; @@ -20,13 +21,16 @@ const OnboardingRoadmapModal: React.FC = () => { const open = progress.panelMode === 'roadmap'; const close = () => progress.setPanelMode('expanded'); - const stage1Done = STAGE_GROUPS[0].steps.every((s) => - progress.completedSteps.includes(s.id), - ); + const unlockedIds = useUnlockedStepIds(); - const currentStep = progress.currentStepId - ? findStepById(progress.currentStepId) - : STEPS.find((s) => !progress.completedSteps.includes(s.id)); + // Current = first unlocked, not-yet-done step (locked ones wait their turn). + const currentStep = (() => { + const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null; + if (explicit && !progress.completedSteps.includes(explicit.id) && unlockedIds.has(explicit.id)) { + return explicit; + } + return STEPS.find((s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id)); + })(); const totalDone = progress.completedSteps.length; const total = STEPS.length; @@ -120,13 +124,8 @@ const OnboardingRoadmapModal: React.FC = () => { const stageDone = group.steps.filter((s) => progress.completedSteps.includes(s.id), ).length; - const isLocked = gi === 1 && !stage1Done; - const isInProgress = !isLocked && stageDone < group.steps.length; - const stageLabel = isLocked - ? 'LOCKED' - : isInProgress - ? 'IN PROGRESS' - : 'COMPLETE'; + const isInProgress = stageDone < group.steps.length; + const stageLabel = isInProgress ? 'IN PROGRESS' : 'COMPLETE'; return ( { fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', - color: isLocked - ? c.text.tertiary - : isInProgress - ? c.accent.primary - : c.text.secondary, + color: isInProgress ? c.accent.primary : c.text.secondary, }} > STAGE {gi + 1} · {stageLabel} @@ -162,7 +157,7 @@ const OnboardingRoadmapModal: React.FC = () => { fontSize: 14, fontWeight: 600, mb: 0.8, - color: isLocked ? c.text.tertiary : c.text.primary, + color: c.text.primary, }} > {STAGE_LABELS[group.stage]} @@ -170,12 +165,15 @@ const OnboardingRoadmapModal: React.FC = () => { {group.steps.map((step) => { const isDone = progress.completedSteps.includes(step.id); - const isCurrent = currentStep?.id === step.id && !isDone; + const isStepLocked = !isDone && !unlockedIds.has(step.id); + const isCurrent = + currentStep?.id === step.id && !isDone && !isStepLocked; + const lockHint = isStepLocked ? unlockHintFor(step.id) : null; return ( { - if (isLocked) return; + if (isStepLocked) return; // Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees. if (progress.running) { onboardingDirector.cancelStep(); @@ -194,15 +192,15 @@ const OnboardingRoadmapModal: React.FC = () => { py: 0.45, px: 0.4, borderRadius: `${c.radius.sm}px`, - cursor: isLocked ? 'default' : 'pointer', - opacity: isLocked ? 0.55 : 1, + cursor: isStepLocked ? 'default' : 'pointer', + opacity: isStepLocked ? 0.55 : 1, transition: 'background 0.12s', - '&:hover': isLocked + '&:hover': isStepLocked ? {} : { bgcolor: c.bg.secondary }, }} > - {isLocked ? ( + {isStepLocked ? ( ) : isDone ? ( { > {step.title} - {isCurrent && ( + {isCurrent ? ( { > current - )} + ) : lockHint ? ( + + {lockHint} + + ) : null} ); })} diff --git a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts new file mode 100644 index 00000000..b0dc4bfd --- /dev/null +++ b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts @@ -0,0 +1,45 @@ +// Soft, earned unlocks for the onboarding panel. A locked step is still fully +// usable in the app, this only gates the guided spotlight + shows a lock icon +// with a one-line teaser, so the tour reveals things in an order you earn by +// doing real actions. Unlocks fire off the same milestone predicates the +// skipIf scanner uses, so exploring (e.g. opening a browser yourself) unlocks +// the next thing immediately, never punished for going off-script. + +import { useMemo } from 'react'; +import type { RootState } from '@/shared/state/store'; +import { useAppSelector } from '@/shared/hooks'; +import { hasAnyAgentLaunched, hasAnyBrowserSpawned } from './skipPredicates'; +import { STEPS } from './index'; + +interface UnlockRule { + by: (s: RootState) => boolean; + hint: string; +} + +// Steps without a rule are unlocked from the start (the get-started entry points). +const RULES: Record = { + enable_actions: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + use_browser: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + install_skill: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + make_app: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + agent_control_agents: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + agent_use_browser: { by: hasAnyBrowserSpawned, hint: 'Open a browser' }, +}; + +export function isStepUnlocked(stepId: string, s: RootState): boolean { + const rule = RULES[stepId]; + return rule ? rule.by(s) : true; +} + +export function unlockHintFor(stepId: string): string | null { + return RULES[stepId]?.hint ?? null; +} + +/** Set of currently-unlocked step ids. Keyed on a stable string so the selector + * only re-renders when the unlock set actually changes. */ +export function useUnlockedStepIds(): Set { + const key = useAppSelector((s) => + STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'), + ); + return useMemo(() => new Set(key ? key.split('|') : []), [key]); +} From a126c42a073cedee00e96ed602852cc4ad3e1461 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 10 Jun 2026 16:32:25 -0700 Subject: [PATCH 5/5] [eric] free-trial: hand the wheel back when a real model is connected mid-trial (key or 9router sub) --- backend/apps/agents/agent_manager.py | 2 +- backend/apps/settings/settings.py | 17 +++++++++++++++ backend/apps/subscription/free_trial.py | 28 ++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index d5f51cce..a117b7d0 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1602,7 +1602,7 @@ class AgentManager: "ANTHROPIC_BASE_URL": proxy_url, # Pin subagent ids; CLI default 'claude-haiku-4-5-20251001' # gets rejected by Pro's surface as "No credentials for provider: anthropic". - # (Free-trial down-routes all of these to Haiku server-side.) + # (Free-trial clamps to its allowed Claude set + weights credits server-side.) "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 09d99c64..b2d89e1c 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -143,6 +143,23 @@ async def update_settings(body: AppSettings): for k in SERVER_OWNED_FIELDS: setattr(body, k, getattr(old, k, None)) + # If the user connects their own model while the free trial is armed, hand + # the wheel back to their provider. Without this, connection_mode (server- + # owned, so the loop above just restored it to "free-trial") would keep them + # pinned to the forced Haiku lane even though they pasted a real key. + if getattr(old, "connection_mode", "own_key") == "free-trial": + from backend.apps.subscription.free_trial import _has_own_model + if _has_own_model(body): + body.connection_mode = "own_key" + body.free_trial_token = None + body.free_trial_remaining = None + try: + import asyncio as _aio + from backend.apps.nine_router import sync_pro_routing as _spr + _aio.create_task(_spr(body)) # drop the now-stale free-trial 9router node + except Exception: + pass + secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", "openswarm_bearer_token", "free_trial_token", "installation_id"} diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py index b3273611..622c0945 100644 --- a/backend/apps/subscription/free_trial.py +++ b/backend/apps/subscription/free_trial.py @@ -74,7 +74,7 @@ def _fingerprint(settings_obj) -> str | None: def _has_own_model(s) -> bool: - """True if the user already has any real model path; never shadow it.""" + """True if the user already has any real model path in settings; never shadow it.""" if any(getattr(s, k, None) for k in ( "anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -90,6 +90,23 @@ def _has_own_model(s) -> bool: return False +async def _has_connected_subscription() -> bool: + """True if 9Router holds a live Claude/ChatGPT/Gemini subscription. Those + connections live in 9Router, not settings, so the sync check above misses + them; this catches a sub connected while the trial was armed.""" + try: + from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers + if not _9r_running(): + return False + conns = await _9r_providers() + return any( + c.get("isActive") and c.get("provider") in ("claude", "codex", "gemini-cli") + for c in conns + ) + except Exception: + return False + + def _proxy_base(settings_obj) -> str: return (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/") @@ -117,9 +134,14 @@ async def arm_free_trial(settings_obj) -> dict: free-trial mode. Guarded: never arms over a real key/subscription.""" if not _enabled(): return {"armed": False, "reason": "disabled"} - if getattr(settings_obj, "connection_mode", "own_key") not in ("own_key", "free-trial"): + mode = getattr(settings_obj, "connection_mode", "own_key") + if mode not in ("own_key", "free-trial"): return {"armed": False, "reason": "other_mode"} - if _has_own_model(settings_obj): + if _has_own_model(settings_obj) or await _has_connected_subscription(): + # A real model exists now (key, custom provider, or a 9Router sub). If we + # were on the free lane, hand the wheel back instead of re-arming. + if mode == "free-trial": + await clear_free_trial(settings_obj) return {"armed": False, "reason": "has_model"} fp = _fingerprint(settings_obj)