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

379 lines
15 KiB
Python

"""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}