"""Desktop-side subscription endpoints.""" from __future__ import annotations import json import logging import os from contextlib import asynccontextmanager from typing import Optional import httpx from fastapi import HTTPException from pydantic import BaseModel from backend.config.Apps import SubApp from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL from backend.apps.settings.settings import SETTINGS_FILE, load_settings, save_settings_async logger = logging.getLogger(__name__) @asynccontextmanager async def subscription_lifespan(): yield subscription = SubApp("subscription", subscription_lifespan) def p_proxy_url() -> str: """Cloud router base URL. Overridable per-user via settings, falling back to the module-default. No trailing slash.""" settings_obj = load_settings() url = (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL) return url.rstrip("/") async def p_sync_pro_routing(settings_obj) -> None: """Mirror connection state into 9Router's Claude lane (WebSearch on non-Claude primaries). PUT /api/settings no longer carries these fields, so the state-change endpoints here are the only trigger left.""" try: from backend.apps.nine_router import sync_pro_routing await sync_pro_routing(settings_obj) except Exception as e: logger.debug("pro routing sync skipped: %s", e) async def p_clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None: """Revert to own_key mode and drop OpenSwarm Pro routing state. `drop_bearer=True` (the default) is the original behavior, used when the cloud reports the bearer as revoked (401) or the subscription as past its grace period (402); the bearer is dead so we have to clear it. `drop_bearer=False` is used by the explicit user-initiated /disconnect endpoint: the bearer still authenticates the user's account at api.me (Settings/AccountCard, future profile endpoints), they just don't want /v1/messages routed through OpenSwarm Pro anymore. Without this branch the AccountCard would say "signed in as alice@..." while the backend bearer was gone, surfacing as 401s on every cloud call.""" settings_obj.connection_mode = "own_key" if drop_bearer: settings_obj.openswarm_bearer_token = None settings_obj.openswarm_subscription_plan = None settings_obj.openswarm_subscription_expires = None settings_obj.openswarm_usage_cached = None await save_settings_async(settings_obj) p_sync_subscription_identity(settings_obj) await p_sync_pro_routing(settings_obj) def p_sync_subscription_identity(settings_obj) -> None: """Push the installation's current subscription state into service-sync person properties so every event from this user is segmentable by plan / paying-vs-free. Safe to call from hot paths; service-sync is fire-and-forget and swallows errors internally.""" try: from backend.apps.service.client import identify as p_identify except Exception: return mode = getattr(settings_obj, "connection_mode", "own_key") is_paying = mode == "openswarm-pro" and bool( getattr(settings_obj, "openswarm_bearer_token", None) ) props = { "connection_mode": mode, "plan": getattr(settings_obj, "openswarm_subscription_plan", None) if is_paying else "free", "is_paying_customer": is_paying, } expires = getattr(settings_obj, "openswarm_subscription_expires", None) if is_paying and expires: props["subscription_expires"] = expires try: p_identify(props) except Exception as e: logger.debug("identify sync failed: %s", e) # --------------------------------------------------------------------------- POST /api/subscription/activate --------------------------------------------------------------------------- class ActivateRequest(BaseModel): token: str plan: Optional[str] = None expires: Optional[str] = None # ISO 8601 @subscription.router.post("/activate") async def activate(body: ActivateRequest): """Renderer calls this after catching an openswarm://auth deep link. Validates the bearer by calling the cloud /api/me, then persists it to settings. On success the desktop app flips into openswarm-pro mode for subsequent Claude requests. """ if not body.token or len(body.token) < 16: raise HTTPException(status_code=400, detail="Invalid token") proxy = p_proxy_url() try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.get( f"{proxy}/api/me", headers={"Authorization": f"Bearer {body.token}"}, ) except httpx.HTTPError as e: raise HTTPException( status_code=502, detail=f"Could not reach subscription service: {e}", ) if r.status_code == 401: raise HTTPException(status_code=401, detail="Token rejected by service") if r.status_code >= 400: raise HTTPException( status_code=r.status_code, detail=r.text[:200] or "Service error", ) me = r.json() # Persist to settings. Prefer cloud-reported values; fall back to the deep-link's own fields if cloud is sparse. settings_obj = load_settings() settings_obj.connection_mode = "openswarm-pro" settings_obj.openswarm_bearer_token = body.token settings_obj.openswarm_proxy_url = proxy settings_obj.openswarm_subscription_plan = ( me.get("plan") or body.plan or "pro" ) period_end = me.get("current_period_end") if isinstance(period_end, (int, float)): # cloud returns unix ms from datetime import datetime, timezone settings_obj.openswarm_subscription_expires = ( datetime.fromtimestamp(period_end / 1000, tz=timezone.utc).isoformat() ) elif body.expires: settings_obj.openswarm_subscription_expires = body.expires usage = me.get("usage") if isinstance(usage, dict): settings_obj.openswarm_usage_cached = usage await save_settings_async(settings_obj) p_sync_subscription_identity(settings_obj) await p_sync_pro_routing(settings_obj) return {"ok": True, "plan": settings_obj.openswarm_subscription_plan} # --------------------------------------------------------------------------- GET /api/subscription/status --------------------------------------------------------------------------- @subscription.router.get("/status") async def status(): """Consolidated view for the Settings card. Reads persisted plan/expires, polls cloud for live usage when a bearer is present.""" settings_obj = load_settings() bearer = getattr(settings_obj, "openswarm_bearer_token", None) plan = getattr(settings_obj, "openswarm_subscription_plan", None) expires = getattr(settings_obj, "openswarm_subscription_expires", None) mode = getattr(settings_obj, "connection_mode", "own_key") if mode != "openswarm-pro" or not bearer: return { "connected": False, "connection_mode": mode, } # Best-effort live fetch; surface stale cache if cloud is unreachable. Network errors leave upstream_code=None so we keep the cached state; only explicit 401/402 from the cloud trigger a local clear. live_usage = None live_status = None upstream_code: Optional[int] = None try: async with httpx.AsyncClient(timeout=5.0) as client: r = await client.get( f"{p_proxy_url()}/api/me", headers={"Authorization": f"Bearer {bearer}"}, ) upstream_code = r.status_code if r.status_code == 200: me = r.json() live_usage = me.get("usage") live_status = me.get("status") # Update cache for offline display. if isinstance(live_usage, dict): settings_obj.openswarm_usage_cached = live_usage await save_settings_async(settings_obj) except httpx.HTTPError as e: logger.debug("subscription/status live fetch failed: %s", e) # Cloud says the bearer is gone (401) or the sub is past its grace period (402); drop local credentials so the desktop stops routing through a dead subscription. Settings UI sees connected=False and falls back to the Subscribe CTA; chat reverts to own_key routing. if upstream_code in (401, 402): await p_clear_subscription(settings_obj) return { "connected": False, "connection_mode": "own_key", "reason": "revoked" if upstream_code == 401 else "expired", "last_plan": plan, } return { "connected": True, "connection_mode": mode, "plan": plan, "status": live_status or "active", "expires": expires, "usage": live_usage or getattr(settings_obj, "openswarm_usage_cached", None), } # --------------------------------------------------------------------------- POST /api/subscription/sync --------------------------------------------------------------------------- @subscription.router.post("/sync") async def sync(): """Reconciles local subscription state with Stripe via the cloud router. Called once per app launch from the renderer so a missed webhook (or a webhook processed by older code) doesn't leave a user wedged in a stale state forever. No-op when not in openswarm-pro mode. Best-effort: network failures are swallowed; the caller still gets a 200 with whatever local state we already had.""" # Lazy-import the service-sync helper so subscription/router doesn't pay the cost when analytics are disabled. from backend.apps.service.client import sync as p_sync settings_obj = load_settings() bearer = getattr(settings_obj, "openswarm_bearer_token", None) mode = getattr(settings_obj, "connection_mode", "own_key") if mode != "openswarm-pro" or not bearer: p_sync(settings_obj.model_dump()) return {"ok": True, "synced": False, "connection_mode": mode} try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post( f"{p_proxy_url()}/api/subscription/sync", headers={"Authorization": f"Bearer {bearer}"}, ) except httpx.HTTPError as e: logger.debug("subscription/sync live fetch failed: %s", e) p_sync(settings_obj.model_dump()) return {"ok": True, "synced": False, "reason": "network"} # Same 401/402 handling as /status: if Stripe-side reconciliation proves the bearer is dead or the sub expired, clear local state so the app reverts to own_key instead of hammering a useless token. if r.status_code in (401, 402): await p_clear_subscription(settings_obj) reason = "revoked" if r.status_code == 401 else "expired" p_sync(settings_obj.model_dump()) return { "ok": True, "synced": False, "connected": False, "reason": reason, } if r.status_code != 200: logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200]) p_sync(settings_obj.model_dump()) return {"ok": True, "synced": False, "reason": "upstream"} data = r.json() cloud_plan = data.get("plan") period_end_ms = data.get("current_period_end") # Only touch local fields the cloud explicitly confirmed; don't paper over missing keys with defaults that would downgrade an older record. if cloud_plan: settings_obj.openswarm_subscription_plan = cloud_plan if isinstance(period_end_ms, (int, float)) and period_end_ms > 0: from datetime import datetime, timezone settings_obj.openswarm_subscription_expires = ( datetime.fromtimestamp(period_end_ms / 1000, tz=timezone.utc).isoformat() ) await save_settings_async(settings_obj) p_sync_subscription_identity(settings_obj) p_sync(settings_obj.model_dump()) return { "ok": True, "synced": bool(data.get("synced")), "plan": cloud_plan, "status": data.get("status"), "expires": settings_obj.openswarm_subscription_expires, } # --------------------------------------------------------------------------- POST /api/subscription/portal --------------------------------------------------------------------------- @subscription.router.post("/portal") async def portal(): """Returns a Stripe Customer Portal URL. Renderer opens it in the system browser via shell.openExternal.""" settings_obj = load_settings() bearer = getattr(settings_obj, "openswarm_bearer_token", None) if not bearer: raise HTTPException(status_code=400, detail="Not subscribed") async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post( f"{p_proxy_url()}/api/billing/portal", headers={"Authorization": f"Bearer {bearer}"}, ) if r.status_code >= 400: raise HTTPException(status_code=r.status_code, detail=r.text[:200]) data = r.json() 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 --------------------------------------------------------------------------- @subscription.router.post("/disconnect") async def disconnect(): """Reverts to own_key routing mode while keeping the user signed in. Does NOT cancel the Stripe subscription (use the portal for that) and does NOT sign the user out of OpenSwarm (use /api/auth/signout for that). Useful when a user wants to temporarily route through their own API key without losing their account state.""" await p_clear_subscription(load_settings(), drop_bearer=False) return {"ok": True}