[eric] OpenSwarm Pro lifecycle fixes: cancel-in-grace shows access-until-X with Resubscribe, distinct ended and reconnect states, app-launch /sync reconciles

with Stripe, auto-revert to own_key on revoke/expire, PostHog identify segments events by plan, browser agent + capacity UX: browser sub-agents inherit the parent's Anthropic
  pick properly, spawned browser cards auto-close on natural completion, OpenSwarm-servers-maxed card with Discord waitlist CTA, auto-clear local subscription state on cloud
  revoke/expire
This commit is contained in:
ciregenz
2026-04-16 13:56:34 -07:00
parent 4fb63ad688
commit 828dfdb18b
11 changed files with 308 additions and 25 deletions
+9 -2
View File
@@ -878,7 +878,7 @@ async def run_browser_agent(
logger.info(f"Browser agent {session_id}: navigated to {initial_url}: {nav_result.get('text', nav_result.get('error', ''))}")
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import (
_find_builtin_model,
resolve_model_id_for_sdk,
@@ -925,7 +925,13 @@ async def run_browser_agent(
"action_log": [],
"final_screenshot": None,
}
client = get_anthropic_client(browser_settings)
# Route the client based on the resolved model id, not just
# connection_mode. Without this, a pinned-route value like "sonnet-cc"
# resolves to "cc/claude-sonnet-4-6" but the old get_anthropic_client()
# still returned an OpenSwarm-proxy client (because connection_mode was
# openswarm-pro), which then rejected the cc/ prefix and surfaced as a
# misleading "OpenSwarm servers are busy" error.
client = get_anthropic_client_for_model(browser_settings, api_model)
# Resume prior conversation on this browser if we have one cached. This
# lets the sub-agent skip the "take a screenshot to figure out where I am"
@@ -1372,6 +1378,7 @@ async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: s
y=100,
width=1280,
height=800,
spawned_by=parent_session_id,
)
dashboard.layout.browser_cards[browser_id] = card
dashboard.updated_at = datetime.now()
+16
View File
@@ -155,6 +155,22 @@ async def analytics_lifespan():
id_props["use_case"] = settings.user_use_case
if getattr(settings, "user_referral_source", None):
id_props["referral_source"] = settings.user_referral_source
# Subscription context so every event from this installation can be
# sliced by plan / paying-vs-free in PostHog. Refreshed on activate,
# sync, and disconnect so these values stay current without waiting
# for the next app launch.
mode = getattr(settings, "connection_mode", "own_key")
plan = getattr(settings, "openswarm_subscription_plan", None)
is_paying = mode == "openswarm-pro" and bool(
getattr(settings, "openswarm_bearer_token", None)
)
id_props["connection_mode"] = mode
id_props["plan"] = plan if is_paying else "free"
id_props["is_paying_customer"] = is_paying
if is_paying and getattr(settings, "openswarm_subscription_expires", None):
id_props["subscription_expires"] = settings.openswarm_subscription_expires
identify(id_props)
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
+4
View File
@@ -36,6 +36,10 @@ class BrowserCardPosition(BaseModel):
y: float = 0
width: float = 1280
height: float = 800
# Agent session id that spawned this browser, or None for user-created.
# Used by the frontend to auto-remove the browser when its owner agent
# reaches a terminal completed/error state.
spawned_by: Optional[str] = None
class DashboardLayout(BaseModel):
+20
View File
@@ -152,3 +152,23 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
)
raise ValueError("No AI provider configured. Set an API key or connect a subscription.")
def get_anthropic_client_for_model(settings: AppSettings, api_model: str) -> anthropic.AsyncAnthropic:
"""Return a client configured for the given resolved model id.
When api_model carries a 9Router prefix (cc/, cx/, gc/, gh/), the client
targets 9Router directly — even if connection_mode is openswarm-pro. This
is what lets pinned-route models like "sonnet-cc" actually reach the
user's own subscription instead of getting sent through the managed proxy
with an unrecognizable model id.
Otherwise delegates to get_anthropic_client() for the default mode-driven
routing.
"""
import anthropic
if isinstance(api_model, str) and api_model.startswith(("cc/", "cx/", "gc/", "gh/")):
return anthropic.AsyncAnthropic(
api_key="9router",
base_url="http://localhost:20128",
)
return get_anthropic_client(settings)
+132 -7
View File
@@ -44,6 +44,48 @@ def _write_settings(settings_obj) -> None:
json.dump(settings_obj.model_dump(), f, indent=2)
def _clear_subscription(settings_obj) -> None:
"""Revert to own_key mode and drop all OpenSwarm Pro state. Used by the
explicit /disconnect endpoint and by /status when the cloud reports the
bearer as revoked (401) or the subscription as past its grace period
(402) — so a canceled/expired user flips back to BYO routing cleanly
instead of hammering a dead token."""
settings_obj.connection_mode = "own_key"
settings_obj.openswarm_bearer_token = None
settings_obj.openswarm_subscription_plan = None
settings_obj.openswarm_subscription_expires = None
settings_obj.openswarm_usage_cached = None
_write_settings(settings_obj)
_sync_subscription_identity(settings_obj)
def _sync_subscription_identity(settings_obj) -> None:
"""Push the installation's current subscription state into PostHog person
properties so every event from this user is segmentable by plan /
paying-vs-free. Safe to call from hot paths — PostHog is fire-and-forget
and swallows errors internally."""
try:
from backend.apps.analytics.collector import identify as _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:
_identify(props)
except Exception as e:
logger.debug("identify sync failed: %s", e)
# ---------------------------------------------------------------------------
# POST /api/subscription/activate
# ---------------------------------------------------------------------------
@@ -112,6 +154,7 @@ async def activate(body: ActivateRequest):
settings_obj.openswarm_usage_cached = usage
_write_settings(settings_obj)
_sync_subscription_identity(settings_obj)
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
@@ -136,14 +179,18 @@ async def status():
}
# 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"{_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")
@@ -155,6 +202,19 @@ async def status():
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):
_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,
@@ -165,6 +225,77 @@ async def status():
}
# ---------------------------------------------------------------------------
# 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."""
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:
return {"ok": True, "synced": False, "connection_mode": mode}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
f"{_proxy_url()}/api/subscription/sync",
headers={"Authorization": f"Bearer {bearer}"},
)
except httpx.HTTPError as e:
logger.debug("subscription/sync live fetch failed: %s", e)
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):
_clear_subscription(settings_obj)
return {
"ok": True,
"synced": False,
"connected": False,
"reason": "revoked" if r.status_code == 401 else "expired",
}
if r.status_code != 200:
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
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()
)
_write_settings(settings_obj)
_sync_subscription_identity(settings_obj)
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
# ---------------------------------------------------------------------------
@@ -198,11 +329,5 @@ async def disconnect():
"""Clears local bearer + reverts to own_key mode. Does NOT cancel the
Stripe subscription (use the portal for that). Useful when a user wants
to temporarily route through their own API key."""
settings_obj = load_settings()
settings_obj.connection_mode = "own_key"
settings_obj.openswarm_bearer_token = None
settings_obj.openswarm_subscription_plan = None
settings_obj.openswarm_subscription_expires = None
settings_obj.openswarm_usage_cached = None
_write_settings(settings_obj)
_clear_subscription(load_settings())
return {"ok": True}
+10
View File
@@ -6,6 +6,7 @@ import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { API_BASE } from '@/shared/config';
import {
setAppVersion,
setUpdateAvailable,
@@ -169,6 +170,15 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
// missed webhook (cancel, upgrade, renewal) can't leave the user
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
// we simply keep whatever local state we already had.
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => { /* offline — next launch will reconcile */ });
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
@@ -67,7 +67,7 @@ interface OpenSwarmErrorInfo {
title: string;
detail: string;
ctaLabel?: string;
ctaAction?: 'upgrade' | 'retry' | 'settings';
ctaAction?: 'upgrade' | 'retry' | 'settings' | 'waitlist';
}
// Turn a raw Claude-CLI / cloud error string into a user-friendly card.
@@ -92,10 +92,10 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
return {
kind: 'capacity',
title: 'OpenSwarm servers are busy',
detail: "We're hitting capacity on our end — please retry in a moment. If this keeps happening, contact support.",
ctaLabel: 'Try again',
ctaAction: 'retry',
title: 'OpenSwarm servers maxed',
detail: "We're at full capacity right now. Thanks for your patience — we'll get you back in as soon as we can. Join our Discord and we'll let you know the moment things open up.",
ctaLabel: 'Join waitlist',
ctaAction: 'waitlist',
};
}
// Auth / subscription problems
@@ -888,6 +888,10 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
} else if (openswarmError.ctaAction === 'settings') {
// Best-effort: dispatch a DOM event the Settings modal listens to
window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } }));
} else if (openswarmError.ctaAction === 'waitlist') {
const url = 'https://discord.com/channels/1486442924391796896/1486442927554170892';
if (api?.openExternal) api.openExternal(url);
else window.open(url, '_blank');
}
}}
sx={{
+75 -8
View File
@@ -137,6 +137,12 @@ interface OpenSwarmProStatus {
plan?: string | null;
status?: string | null;
expires?: string | null;
// When the cloud reports the bearer as revoked (401) or the sub as past
// its grace period (402), backend clears local state and returns
// connected=false with a reason + last_plan so the UI can distinguish
// "your subscription ended" from "never subscribed."
reason?: 'revoked' | 'expired' | null;
last_plan?: string | null;
usage?: {
// Live utilization from Claude's /api/oauth/usage — 0-100 percent of the
// shared pool subscription's 5h window consumed. Updated every ~30s.
@@ -256,11 +262,13 @@ const OpenSwarmProCard: React.FC = () => {
OpenSwarm Pro
</Typography>
{isConnected && (
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}20` }}>
<Typography sx={{ fontSize: '0.7rem', color: c.accent.primary, fontWeight: 600 }}>
{planLabel}
</Typography>
</Box>
<Box
component="img"
src="./logo.png"
alt={planLabel}
title={planLabel}
sx={{ width: 18, height: 18, borderRadius: 0.5 }}
/>
)}
{!isConnected && (
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}15` }}>
@@ -274,6 +282,20 @@ const OpenSwarmProCard: React.FC = () => {
{isConnected ? (
<>
{/* Canceled-in-grace banner: user canceled in Stripe but still
inside the paid period. Show a clear "scheduled to cancel"
state so they're not surprised when access stops. */}
{status.status === 'canceled' && (
<Box sx={{
px: 1.2, py: 0.6, mb: 1.2, borderRadius: `${c.radius.sm}px`,
bgcolor: `${c.status.warning}15`, border: `1px solid ${c.status.warning}40`,
}}>
<Typography sx={{ fontSize: '0.72rem', color: c.status.warning, fontWeight: 500 }}>
Subscription canceled you still have access until {expiresLabel || 'the end of the period'}.
</Typography>
</Box>
)}
{/* Usage bar — percentage only, no raw counts */}
<Box sx={{ mb: 1.2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
@@ -310,24 +332,69 @@ const OpenSwarmProCard: React.FC = () => {
</Typography>
)}
</Box>
{expiresLabel && (
{expiresLabel && status.status !== 'canceled' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mb: 1.5 }}>
{status.status === 'canceled' ? 'Expires' : 'Renews'} on {expiresLabel}
Renews on {expiresLabel}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
{status.status === 'canceled' && (
<Button
onClick={handleSubscribe}
disabled={busy !== null}
size="small"
variant="contained"
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
>
Resubscribe
</Button>
)}
<Button
onClick={handleManage}
disabled={busy !== null}
size="small"
variant="contained"
variant={status.status === 'canceled' ? 'outlined' : 'contained'}
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
>
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
</Button>
</Box>
</>
) : status.reason === 'expired' && status.last_plan ? (
// Truly expired: the bearer's subscription ended past its grace
// period. Don't show the new-user "Subscribe" prompt — make it
// clear what happened and invite them back.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro subscription has ended. Resubscribe to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Resubscribe
</Button>
</>
) : status.reason === 'revoked' && status.last_plan ? (
// Token revoked but subscription existed — different CTA language
// so the user knows this isn't a billing issue.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
Your OpenSwarm Pro access token was revoked. Sign back in to reconnect.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Reconnect
</Button>
</>
) : (
// Genuine new user — never had a subscription on this machine.
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
One subscription, no Claude account needed. We handle everything behind the scenes.
@@ -50,6 +50,10 @@ export interface BrowserCardPosition {
width: number;
height: number;
zOrder: number;
// Agent session id that spawned this browser. null/undefined for
// user-created. Used to auto-remove the browser when its owner agent
// reaches a terminal completed/error state.
spawned_by?: string | null;
}
export interface DashboardLayoutState {
+28 -2
View File
@@ -16,7 +16,7 @@ import {
closeSessionFromWs,
trackAgentNotification,
} from '../state/agentsSlice';
import { addBrowserCardFromBackend, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
type WSEvent = {
event: string;
@@ -129,6 +129,20 @@ class WebSocketManager {
if (data.status === 'running' && session_id) {
store.dispatch(trackAgentNotification(session_id));
}
// Auto-remove browsers spawned by this agent when it reaches a
// terminal state on its own. agent:closed only fires when the user
// clicks X to close the session, so without this hook the browser
// cards would linger after natural completion or error. 'stopped'
// is intentionally skipped to preserve the "inspect after manual
// stop" affordance — matching the agent:closed branch below.
if (session_id && (data.status === 'completed' || data.status === 'error')) {
const browserCards = store.getState().dashboardLayout.browserCards;
for (const card of Object.values(browserCards)) {
if (card.spawned_by === session_id) {
store.dispatch(removeBrowserCard(card.browser_id));
}
}
}
break;
case 'agent:message':
@@ -222,10 +236,11 @@ class WebSocketManager {
case 'agent:closed':
if (session_id) {
const closedStatus = data.status ?? 'stopped';
store.dispatch(closeSessionFromWs({
id: session_id,
name: data.name ?? 'Untitled',
status: data.status ?? 'stopped',
status: closedStatus,
model: data.model ?? '',
mode: data.mode ?? '',
created_at: data.created_at ?? new Date().toISOString(),
@@ -233,6 +248,17 @@ class WebSocketManager {
cost_usd: data.cost_usd ?? 0,
dashboard_id: data.dashboard_id,
}));
// Auto-delete browsers spawned by this agent when it finishes
// normally or errors out. We intentionally skip 'stopped' — the
// user may want to inspect the browser after manually stopping.
if (closedStatus === 'completed' || closedStatus === 'error') {
const browserCards = store.getState().dashboardLayout.browserCards;
for (const card of Object.values(browserCards)) {
if (card.spawned_by === session_id) {
store.dispatch(removeBrowserCard(card.browser_id));
}
}
}
}
break;
File diff suppressed because one or more lines are too long