Files
openswarm/backend/apps/subscription/free_trial.py
T

261 lines
13 KiB
Python

"""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 asyncio
import hashlib
import logging
import os
import platform
import re
import subprocess
import time
import httpx
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
from backend.apps.settings.settings import load_settings, 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.
P_FP_SALT = "openswarm-free-trial-v1"
def p_enabled() -> bool:
# Default ON as of 1.2.80: the cloud free-trial proxy is live on prod (api.openswarm.com) and arming + metered Haiku were verified end to end. Set OPENSWARM_FREE_TRIAL_ENABLED=0 to force it off. The pool-shed gate + daily global budget on the cloud cap total spend; arming only happens for a truly-unconnected user (no key, no sub), so paid users are never touched.
return os.environ.get("OPENSWARM_FREE_TRIAL_ENABLED", "1") == "1"
def p_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 p_fingerprint(settings_obj) -> str | None:
raw = p_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((P_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 p_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 p_9r_running,
get_providers as p_9r_providers,
NINE_ROUTER_CLAUDE_PRO_NAME,
)
if not p_9r_running():
return False
conns = await p_9r_providers()
# Exclude our OWN managed node: the free trial registers itself as a `claude` connection here, and counting it would make the trial think a real model is connected and clear itself on the next boot (works once, dead on relaunch).
return any(
c.get("isActive")
and c.get("provider") in ("claude", "codex", "gemini-cli")
and c.get("name") != NINE_ROUTER_CLAUDE_PRO_NAME
for c in conns
)
except Exception:
return False
def p_proxy_base(settings_obj) -> str:
return (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
async def p_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"
# Keep Haiku as the face of the free lane while the user has no model of their own (a spent trial still shows "Claude Haiku", with the send gated by the out-of-runs UI); only fall back to "sonnet" once a real key/sub connects so we never pin a paying user to Haiku.
if getattr(settings_obj, "default_model", None) == "haiku" and (
has_own_model(settings_obj) or await p_has_connected_subscription()
):
settings_obj.default_model = "sonnet"
settings_obj.free_trial_token = None
await save_settings_async(settings_obj)
await p_sync_routing(settings_obj)
async def clear_free_trial_on_connect() -> None:
"""Hand the wheel back to a just-connected subscription immediately, instead of
waiting for the next-boot arm_free_trial reconcile. Subscriptions live in 9Router,
not settings, so `apply_settings_update`'s `_has_own_model` clear (which covers keys +
custom providers) can't see them; this is the connect-time equivalent for subs."""
try:
await clear_free_trial(load_settings())
except Exception as e:
logger.debug("clear_free_trial_on_connect skipped: %s", e)
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 p_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"}
own = has_own_model(settings_obj)
has_sub = False
if not own:
# A subscription lives in 9Router, not settings, and 9Router now starts in the BACKGROUND (non-blocking boot), so at first-launch mint time it isn't up yet. Without this wait p_has_connected_subscription() reads False and we'd arm the free trial OVER a real Claude/ChatGPT/Gemini sub, pinning the user to Haiku until they manually reload. Bring 9Router up so the sub is actually visible before we decide. Bounded + idempotent (shares the start lock with the boot auto-start), and skipped when a settings-level model already proves there's nothing to shadow.
try:
from backend.apps.nine_router import ensure_running as p_ensure_9r
await p_ensure_9r()
except Exception:
pass
# 9Router's /api/providers can lag /v1/models (what is_running probes) by a beat on a cold start, so a real sub can read as absent for a sub-second window. Re-check a few times before concluding "no sub", so we never arm over a sub that's merely still loading. CAPPED on purpose: a genuinely sub-less user exhausts these in ~1.2s and falls through to arm, so this never waits on a subscription that doesn't exist.
for p_i in range(5):
if await p_has_connected_subscription():
has_sub = True
break
if p_i < 4:
await asyncio.sleep(0.3)
if own or has_sub:
# 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 = p_fingerprint(settings_obj)
if not fp:
return {"armed": False, "reason": "no_fingerprint"}
base = p_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
# Pin the trial to Haiku, the exact tier the cloud serves a free run as. Critical: a sonnet/opus pick makes the Claude Code CLI attach an `effort`/thinking param (reasoning models), which Haiku 400s on ("does not support the effort parameter"). Using Haiku end to end means the CLI never adds it, so the run just works.
settings_obj.default_model = "haiku"
await save_settings_async(settings_obj)
await p_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 = p_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
# Stash an absolute refill time so the spent nudge can say "fresh runs in ~3h". Set before clearing (clear keeps it) so it survives the hand-back to own_key. Relative -> absolute here because the client reads it much later than we fetched it.
resets_in = data.get("resets_in_seconds")
if isinstance(resets_in, (int, float)) and resets_in > 0:
settings_obj.free_trial_resets_at = time.time() + float(resets_in)
if remaining <= 0:
await clear_free_trial(settings_obj)
return {"connected": False, "runs_remaining": 0, "resets_at": getattr(settings_obj, "free_trial_resets_at", None)}
await save_settings_async(settings_obj)
return {"connected": True, "runs_remaining": remaining, "runs_limit": getattr(settings_obj, "free_trial_runs_limit", None)}