[eric] merge free-trial + value-first onboarding (flag off, reorder gated so no-trial = today's flow)

# Conflicts:
#	frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx
This commit is contained in:
ciregenz
2026-06-10 23:20:18 -07:00
24 changed files with 662 additions and 85 deletions
+37 -11
View File
@@ -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,
_is_unknown_model_error,
@@ -1253,7 +1254,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")),
)
)
@@ -1593,19 +1594,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 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",
}
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")
@@ -2701,12 +2704,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
@@ -2944,6 +2947,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
@@ -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.
+10 -4
View File
@@ -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)
+8 -8
View File
@@ -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}")
+31 -9
View File
@@ -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.
+7
View File
@@ -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
+26 -6
View File
@@ -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",
@@ -140,9 +143,26 @@ 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", "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)
+219
View File
@@ -0,0 +1,219 @@
"""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:
# Default OFF: the cloud free-trial proxy is staging-only, so on a shipped
# build arming would 404. Flip to "1" once the cloud ships to prod and the
# packaged-build fingerprint is verified. Off = the reorder gate falls back
# to today's connect-model-first onboarding (no regression).
return os.environ.get("OPENSWARM_FREE_TRIAL_ENABLED", "0") == "1"
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 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",
)):
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
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("/")
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"}
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) 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)
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)}
+20
View File
@@ -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
# ---------------------------------------------------------------------------
+57
View File
@@ -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")
)
+10 -1
View File
@@ -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(() => {
@@ -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<number>(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';
@@ -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 (
<Box key={group.stage} sx={{ mb: 2 }}>
<Box
@@ -143,11 +142,7 @@ const OnboardingRoadmapModal: React.FC = () => {
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 = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
{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 (
<Box
key={step.id}
onClick={() => {
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 ? (
<LockIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
) : isDone ? (
<CheckCircleIcon
@@ -231,7 +229,7 @@ const OnboardingRoadmapModal: React.FC = () => {
>
{step.title}
</Typography>
{isCurrent && (
{isCurrent ? (
<Typography
sx={{
fontSize: 10.5,
@@ -243,7 +241,13 @@ const OnboardingRoadmapModal: React.FC = () => {
>
current
</Typography>
)}
) : lockHint ? (
<Typography
sx={{ fontSize: 10.5, color: c.text.tertiary, whiteSpace: 'nowrap' }}
>
{lockHint}
</Typography>
) : null}
</Box>
);
})}
@@ -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,
@@ -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.
@@ -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.' },
@@ -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 },
@@ -1,6 +1,6 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyAgentLaunched, isYoutubeEnabled } from './skipPredicates';
import { hasAnyAgentLaunched, isYoutubeEnabled, hasModelConnected, hasFreeTrialActive } from './skipPredicates';
// Primary: YouTube summary (needs MCP from step 2). Fallback uses built-in web tools (no MCP).
const YOUTUBE_PROMPT =
@@ -11,12 +11,15 @@ const FALLBACK_PROMPT =
export const step03: OnboardingStep = {
id: 'launch_agent',
stage: 'get_started',
index: 3,
// Value first: this leads WHEN there's a way to run (free trial armed, or a
// model connected). With nothing to run on, it skips so connect-model leads
// instead, which restores today's flow exactly (no trial = no regression).
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',
videoDurationLabel: '0:24',
skipIf: hasAnyAgentLaunched,
skipIf: (s) => hasAnyAgentLaunched(s) || (!hasModelConnected(s) && !hasFreeTrialActive(s)),
requiresDashboard: true,
ops: [
{ kind: 'move_to', target: S.newAgentButton },
@@ -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:
@@ -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<string, UnlockRule> = {
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<string> {
const key = useAppSelector((s) =>
STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'),
);
return useMemo(() => new Set(key ? key.split('|') : []), [key]);
}
@@ -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 (/unknown model|check the model code|\b1211\b|model_not_found/i.test(text)) {
return {
kind: 'auth',
@@ -215,7 +215,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
<DashboardEmptyState c={c} />
<DashboardEmptyState c={c} onLaunch={onToolbarSend} />
) : (
<div
ref={canvas.contentRef}
@@ -3,12 +3,44 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useAppSelector } from '@/shared/hooks';
import {
hasModelConnected,
hasFreeTrialActive,
} from '@/app/components/Onboarding/steps/skipPredicates';
import ChatBubbleTeardrop from '../ChatBubbleTeardrop';
const DashboardEmptyState: React.FC<{ c: ClaudeTokens }> = ({ 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 (
<Box
sx={{
@@ -47,6 +79,48 @@ const DashboardEmptyState: React.FC<{ c: ClaudeTokens }> = ({ c }) => {
</Box>
below to launch your first agent
</Typography>
{showChips && (
<Box
sx={{
mt: 3,
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 1,
maxWidth: 520,
pointerEvents: 'auto',
}}
>
<Typography sx={{ width: '100%', textAlign: 'center', color: c.text.ghost, fontSize: '0.8rem', mb: 0.5 }}>
or try one of these
</Typography>
{STARTER_PROMPTS.map((prompt) => (
<Box
component="button"
key={prompt}
onClick={() => 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}
</Box>
))}
</Box>
)}
</Box>
);
};
+5 -1
View File
@@ -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;