[eric] OpenSwarm Pro: new managed plan alongside BYO — deep-link activation from billing checkout, onboarding + Settings card with live usage, gradient OpenSwarm Pro group in

the model picker, inline error cards for rate-limit/connection failures, debugger startup fix
This commit is contained in:
ciregenz
2026-04-15 23:05:02 -07:00
parent 14e1d02bbb
commit 1a68ccfc7b
22 changed files with 948 additions and 66 deletions
+3
View File
@@ -24,3 +24,6 @@ backend/npm-servers/*/node_modules/
frontend/dist/
# Bundled uv binaries (downloaded during build)
backend/uv-bin/
.account-factory
openswarm-cloud
.openswarm-cloud
+11 -4
View File
@@ -1041,11 +1041,18 @@ class AgentManager:
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
# Priority: Anthropic API key (Anthropic models only) → 9Router.
# Non-Anthropic api_types always route through 9Router regardless
# of whether an Anthropic API key is set.
# Priority: openswarm-pro mode → Anthropic API key → 9Router.
# Non-Anthropic api_types always route through 9Router regardless.
from backend.apps.nine_router import is_running as _9r_running
if api_type == "anthropic" and global_settings.anthropic_api_key:
if api_type == "anthropic" 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 ""
options_kwargs["env"] = {
"ANTHROPIC_AUTH_TOKEN": bearer,
"ANTHROPIC_BASE_URL": proxy_url,
}
logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}")
elif api_type == "anthropic" 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")
elif _9r_running():
+5 -1
View File
@@ -336,7 +336,11 @@ async def list_models():
continue
elif api == "anthropic":
has_key = bool(getattr(settings, "anthropic_api_key", None))
if not has_key and "claude" not in connected:
is_openswarm_pro = (
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
and bool(getattr(settings, "openswarm_bearer_token", None))
)
if not has_key and "claude" not in connected and not is_openswarm_pro:
continue
visible.append({
"value": m["value"],
+16 -7
View File
@@ -259,6 +259,7 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
Priority:
- Anthropic model + openswarm-pro mode → bare `model_id` (our cloud proxy)
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ gh/ prefix)
- Unknown names pass through unchanged
@@ -266,8 +267,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
if entry.get("api") == "anthropic" and getattr(settings, "anthropic_api_key", None):
return entry.get("model_id", short_name)
if entry.get("api") == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return entry.get("model_id", short_name)
if getattr(settings, "anthropic_api_key", None):
return entry.get("model_id", short_name)
return entry.get("router_model_id", entry.get("model_id", short_name))
@@ -294,6 +298,11 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# OpenSwarm Pro — route through our cloud proxy
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)
# Direct API key wins
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
@@ -361,10 +370,10 @@ def create_provider(
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "managed":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
@@ -467,8 +476,8 @@ def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
return bool(getattr(settings, "openswarm_auth_token", None))
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return bool(getattr(settings, "openswarm_bearer_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
+9 -9
View File
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
import anthropic
from backend.apps.settings.models import AppSettings
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com"
def _check_9router() -> bool:
@@ -42,8 +42,8 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
return
if p == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
if not getattr(settings, "openswarm_auth_token", None):
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
if not getattr(settings, "openswarm_bearer_token", None):
raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.")
return
if settings.anthropic_api_key:
@@ -81,9 +81,9 @@ 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") == "managed":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return {
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
"auth_token": getattr(settings, "openswarm_bearer_token", "") or "",
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
}
return {"api_key": settings.anthropic_api_key or ""}
@@ -116,10 +116,10 @@ def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "managed":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
@@ -133,10 +133,10 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""
import anthropic
if getattr(settings, "connection_mode", "own_key") == "managed":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return anthropic.AsyncAnthropic(
auth_token=getattr(settings, "openswarm_auth_token", None),
auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=proxy_url,
)
+7
View File
@@ -69,6 +69,13 @@ class AppSettings(BaseModel):
analytics_opt_in: bool = True
installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
# OpenSwarm Pro subscription
connection_mode: str = "own_key" # "own_key" | "openswarm-pro"
openswarm_bearer_token: Optional[str] = None
openswarm_proxy_url: Optional[str] = None # default resolved in credentials.py
openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra"
openswarm_subscription_expires: Optional[str] = None # ISO 8601
openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at}
class CustomProvider(BaseModel):
+17 -1
View File
@@ -27,11 +27,27 @@ async def settings_lifespan():
settings = SubApp("settings", settings_lifespan)
def _migrate_legacy_fields(raw: dict) -> dict:
"""Translate deprecated field names/values so they survive into the new schema.
Pre-launch scaffolding used `connection_mode="managed"` and
`openswarm_auth_token`; production names are `"openswarm-pro"` and
`openswarm_bearer_token`. Zero known users are affected, but keep the
mapping for safety.
"""
if raw.get("connection_mode") == "managed":
raw["connection_mode"] = "openswarm-pro"
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
return raw
def load_settings() -> AppSettings:
"""Load settings from JSON file, returning defaults if not found."""
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE) as f:
settings = AppSettings(**json.load(f))
raw = _migrate_legacy_fields(json.load(f))
settings = AppSettings(**raw)
if settings.default_system_prompt is None:
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
return settings
+208
View File
@@ -0,0 +1,208 @@
"""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
logger = logging.getLogger(__name__)
@asynccontextmanager
async def subscription_lifespan():
yield
subscription = SubApp("subscription", subscription_lifespan)
def _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("/")
def _write_settings(settings_obj) -> None:
"""Persist AppSettings to disk. Mirrors backend/apps/settings/settings.py
_save_settings to avoid importing a private module member."""
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings_obj.model_dump(), f, indent=2)
# ---------------------------------------------------------------------------
# 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 = _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
_write_settings(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.
live_usage = None
live_status = 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}"},
)
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
_write_settings(settings_obj)
except httpx.HTTPError as e:
logger.debug("subscription/status live fetch failed: %s", e)
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/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"{_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")}
# ---------------------------------------------------------------------------
# POST /api/subscription/disconnect
# ---------------------------------------------------------------------------
@subscription.router.post("/disconnect")
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)
return {"ok": True}
+2 -1
View File
@@ -38,11 +38,12 @@ from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.analytics.analytics import analytics
from backend.apps.subscription.router import subscription
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription])
app = main_app.app
app.add_middleware(
+1 -1
View File
@@ -87,7 +87,7 @@ class Directory:
if debug_file.calls_debug_function():
parent_dir.add_child(debug_file)
else:
raise Exception(f"[build_structure]: Entry is not dir or file: {entry.path}")
continue
construct_project_structure(root_dir, self)
# [print(f"[build_structure]: {file}") for file in project_structure]
+58 -1
View File
@@ -12,11 +12,45 @@ const http = require('http');
// (or macOS auto-launch + manual launch overlapping) spawns two independent
// processes — each with its own backend on a different port — resulting in
// one populated window and one empty window.
// Register openswarm:// protocol handler BEFORE any gotLock branching.
// Must happen synchronously at the top of main.js so the OS knows this
// binary is the default handler even before whenReady fires.
if (process.defaultApp) {
// Dev run: `electron .` needs the entry-script path to re-launch cleanly.
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('openswarm', process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient('openswarm');
}
// Pending deep-link captured before mainWindow exists (cold-launch case).
// Flushed to renderer once mainWindow is ready.
let pendingDeepLink = null;
function forwardDeepLinkToRenderer(url) {
if (!url) return;
if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
mainWindow.webContents.send('openswarm:auth-url', url);
} else {
pendingDeepLink = url;
}
}
function extractOpenswarmUrl(argv) {
return argv && argv.find((a) => typeof a === 'string' && a.startsWith('openswarm://'));
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.exit(0);
} else {
app.on('second-instance', () => {
app.on('second-instance', (_event, argv) => {
// Windows/Linux: a `openswarm://...` click lands here because the OS
// re-launches the app with the URL as an argv. We swallow the second
// instance, focus the existing window, and forward the URL to renderer.
const url = extractOpenswarmUrl(argv);
if (url) forwardDeepLinkToRenderer(url);
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
@@ -24,6 +58,14 @@ if (!gotLock) {
});
}
// macOS-only: clicks on openswarm:// links fire this event (instead of
// relaunching the process).
app.on('open-url', (event, url) => {
event.preventDefault();
forwardDeepLinkToRenderer(url);
if (mainWindow) mainWindow.focus();
});
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('enable-gpu-rasterization');
@@ -253,6 +295,15 @@ function createWindow() {
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
});
// Once the renderer has loaded, flush any deep-link URL we captured before
// the window existed (cold-launch via openswarm://).
mainWindow.webContents.once('did-finish-load', () => {
if (pendingDeepLink) {
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
pendingDeepLink = null;
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -317,6 +368,12 @@ function killBackend() {
}
app.whenReady().then(async () => {
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
// in argv; macOS fires open-url AFTER whenReady which we handle above)
// buffer the URL for when mainWindow loads.
const initialDeepLink = extractOpenswarmUrl(process.argv);
if (initialDeepLink) pendingDeepLink = initialDeepLink;
if (process.platform === 'darwin' && !isPackaged) {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
+8
View File
@@ -54,5 +54,13 @@ const { contextBridge, ipcRenderer } = require('electron');
ipcRenderer.on('webview-new-window', listener);
return () => ipcRenderer.removeListener('webview-new-window', listener);
},
// Deep-link callback: fires when the OS opens the app with an
// openswarm://auth?token=... URL (after Stripe-hosted checkout).
onAuthUrl: (cb) => {
const listener = (_event, url) => cb(url);
ipcRenderer.on('openswarm:auth-url', listener);
return () => ipcRenderer.removeListener('openswarm:auth-url', listener);
},
});
})();
+25 -17
View File
@@ -25,6 +25,7 @@ import Analytics from './pages/Analytics/Analytics';
import OnboardingModal from './components/OnboardingModal';
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import { useDeepLink } from '@/shared/hooks/useDeepLink';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
@@ -155,6 +156,11 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
return <>{children}<KeyboardShortcutsHelp /></>;
};
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
return <>{children}</>;
};
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
@@ -244,23 +250,25 @@ const ThemedApp: React.FC = () => {
<ShortcutsProvider>
<SettingsLoader>
<UpdateListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<OnboardingModal />
<DeepLinkListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<OnboardingModal />
</DeepLinkListener>
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
@@ -45,10 +45,10 @@ function isValidEmail(email: string): boolean {
}
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
{ id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true },
{ id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true },
];
const USE_CASES = [
@@ -150,6 +150,21 @@ const OnboardingModal: React.FC = () => {
};
}, []);
// Auto-dismiss when a subscription activates via deep link while the
// modal is open. activateSubscription refetches settings; we watch the
// connection_mode field flipping to openswarm-pro.
useEffect(() => {
if (!open) return;
const mode = (settings.data as any).connection_mode;
const bearer = (settings.data as any).openswarm_bearer_token;
if (mode === 'openswarm-pro' && bearer) {
trackEvent('onboarding.openswarm_pro_activated');
dismiss();
}
// dismiss is stable enough — don't include in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, settings.data]);
const dismiss = async () => {
localStorage.setItem('openswarm_onboarding_seen', 'true');
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
@@ -262,6 +277,28 @@ const OnboardingModal: React.FC = () => {
setConnecting(providerId);
trackEvent('onboarding.provider_selected', { provider: providerId });
// OpenSwarm Pro: no OAuth — just open the pricing/checkout page in the
// system browser. The post-payment openswarm://auth deep link will
// dismiss this modal automatically via useDeepLink → fetchSettings.
if (providerId === 'openswarm-pro') {
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
});
if (r.ok) {
const { url } = await r.json();
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
}
} catch (e) {
console.error('Failed to create checkout session:', e);
}
return;
}
// Delay before calling connect — avoids Claude OAuth rate limit on retries
await new Promise(r => setTimeout(r, 1000));
+38 -9
View File
@@ -202,6 +202,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
const toolItems = useAppSelector((state) => state.tools.items);
// Count the total number of enabled MCP tool permissions (non-deny) across
@@ -226,21 +227,27 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
const MCP_WARNING_THRESHOLD = 20;
// Build flat model list with provider grouping
// Build flat model list with provider grouping. When in openswarm-pro mode
// the Anthropic section is renamed to "OpenSwarm Pro" so the user sees their
// paid subscription is powering the Claude models.
const allModelOptions = useMemo(() => {
const isPro = connectionMode === 'openswarm-pro';
const renameAnthropic = (prov: string) => (isPro && prov === 'Anthropic' ? 'OpenSwarm Pro' : prov);
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
const key = renameAnthropic('Anthropic');
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string; reasoning: boolean }> = [];
const grouped: Record<string, Array<{ value: string; label: string; context_window: number; reasoning: boolean }>> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
const key = renameAnthropic(prov);
grouped[key] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
for (const m of models) {
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: key, reasoning: !!m.reasoning });
}
}
return { flat, grouped };
}, [modelsByProvider, modelsLoaded]);
}, [modelsByProvider, modelsLoaded, connectionMode]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -1096,7 +1103,13 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
// menu layout static avoids the "cursor chases a moving
// target" problem that happens when items above the cursor
// appear/disappear.
const isOpenSwarmPro = prov === 'OpenSwarm Pro';
const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary;
// OpenSwarm Pro uses a warm blue→pink→orange gradient to stand
// out as the recommended paid tier (distinct from plain provider
// brand dots).
const OPENSWARM_GRADIENT =
'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)';
return [
<MenuItem
key={`header-${prov}`}
@@ -1108,8 +1121,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: brandColor,
boxShadow: `0 0 6px ${brandColor}80`,
background: isOpenSwarmPro ? OPENSWARM_GRADIENT : brandColor,
boxShadow: isOpenSwarmPro
? '0 0 8px rgba(229, 107, 196, 0.6)'
: `0 0 6px ${brandColor}80`,
flexShrink: 0,
}} />
<Typography sx={{
@@ -1117,7 +1132,14 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: brandColor,
...(isOpenSwarmPro
? {
background: OPENSWARM_GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}
: { color: brandColor }),
}}>
{prov}
</Typography>
@@ -1133,6 +1155,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
'openswarm pro': 'anthropic',
openai: 'openai',
google: 'gemini',
xai: 'openrouter',
@@ -1144,7 +1167,13 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
onProviderChange(providerMap[provLower] || provLower);
}
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
// OpenSwarm Pro routes Claude models through our proxy —
// they're still Claude, so they support the deferred tool
// loader. Only warn when the user picks a truly non-Claude
// provider (GPT/Gemini/etc).
const provLowerForWarn = prov.toLowerCase();
const isClaudeProvider = provLowerForWarn === 'anthropic' || provLowerForWarn === 'openswarm pro';
if (!isClaudeProvider && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
try {
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
setMcpWarningOpen(true);
@@ -62,6 +62,65 @@ const StreamingCursor: React.FC = () => {
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
interface OpenSwarmErrorInfo {
kind: 'cap' | 'capacity' | 'auth' | 'network';
title: string;
detail: string;
ctaLabel?: string;
ctaAction?: 'upgrade' | 'retry' | 'settings';
}
// Turn a raw Claude-CLI / cloud error string into a user-friendly card.
// Returns null for things that aren't obviously our errors — those fall
// through to normal markdown rendering.
function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
if (!text) return null;
// Rate-limit cap from our cloud
if (/rate_limit_error|reached your OpenSwarm.*plan limit|Usage cap exceeded/i.test(text)) {
const reset = text.match(/Resets in ([\dhms\s]+)/)?.[1];
return {
kind: 'cap',
title: "You've hit your plan limit",
detail: reset
? `Your usage resets in ${reset}. Upgrade to keep going now, or wait for the window to reset.`
: 'Upgrade to keep going now, or wait for your usage window to reset.',
ctaLabel: 'Upgrade plan',
ctaAction: 'upgrade',
};
}
// Upstream capacity / 503
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',
};
}
// Auth / subscription problems
if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) {
return {
kind: 'auth',
title: 'Subscription issue',
detail: "We can't find an active OpenSwarm subscription. Check your billing status.",
ctaLabel: 'Open Settings',
ctaAction: 'settings',
};
}
// Network issues (keep last so it doesn't swallow the specific cases above)
if (/ECONNREFUSED|ENETUNREACH|fetch failed|ETIMEDOUT|network|Could not reach/i.test(text)) {
return {
kind: 'network',
title: 'Network issue',
detail: "Can't reach the OpenSwarm service. Check your internet connection and try again.",
ctaLabel: 'Try again',
ctaAction: 'retry',
};
}
return null;
}
interface ParsedElement {
label: string;
selector: string;
@@ -600,6 +659,11 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
? parseElementContext(rawText)
: { userMessage: rawText, elements: [] };
// Detect friendly OpenSwarm / upstream errors and render a card instead of
// raw "API Error: ..." text. Checks both the wrapped format the Claude CLI
// uses ("API Error: NNN …") and the raw JSON body.
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
React.useEffect(() => {
if (editing) setEditText(rawText);
}, [editing, rawText]);
@@ -782,15 +846,77 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
'& a': { color: c.accent.primary },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
{openswarmError ? (
<Box
sx={{
mt: 0.5,
p: 1.8,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${c.status.warning}40`,
bgcolor: `${c.status.warning}10`,
display: 'flex',
flexDirection: 'column',
gap: 0.7,
}}
>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
{openswarmError.title}
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5 }}>
{openswarmError.detail}
</Typography>
{openswarmError.ctaLabel && (
<Box sx={{ mt: 0.4 }}>
<Button
size="small"
variant="outlined"
onClick={() => {
const api = (window as any).openswarm;
if (openswarmError.ctaAction === 'upgrade') {
const url = 'https://api.openswarm.com/api/stripe/checkout';
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro_plus', billing_interval: 'monthly' }),
})
.then((r) => r.json())
.then(({ url }) => {
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
})
.catch(() => {});
} 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' } }));
}
}}
sx={{
textTransform: 'none',
fontSize: '0.78rem',
borderColor: c.border.medium,
color: c.text.primary,
borderRadius: `${c.radius.md}px`,
'&:hover': { borderColor: c.accent.primary },
}}
>
{openswarmError.ctaLabel}
</Button>
</Box>
)}
</Box>
) : (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
</>
)}
</Box>
)}
</Box>
+244 -3
View File
@@ -39,7 +39,7 @@ import LinearProgress from '@mui/material/LinearProgress';
import Collapse from '@mui/material/Collapse';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
@@ -126,6 +126,236 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
);
};
// ── OpenSwarm Pro managed-subscription card ──
//
// Renders either a "Subscribe" CTA (when not connected) or a live usage +
// Manage/Disconnect card (when connection_mode === 'openswarm-pro'). All
// billing details come from /api/subscription/status at runtime — no
// pricing is hardcoded in this OSS repo.
interface OpenSwarmProStatus {
connected: boolean;
connection_mode?: string;
plan?: string | null;
status?: string | null;
expires?: 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.
utilization?: number;
window_hours?: number;
window_ends_at?: number;
pool_active_accounts?: number;
} | null;
}
const OpenSwarmProCard: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
const refresh = useCallback(async () => {
try {
const r = await fetch(`${API_BASE}/subscription/status`);
if (r.ok) setStatus(await r.json());
} catch {
// silently ignore — cloud might be offline
}
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
const handleSubscribe = async () => {
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
});
if (r.ok) {
const { url } = await r.json();
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
}
} catch (e) {
console.error('Failed to create checkout session:', e);
}
};
const handleManage = async () => {
setBusy('manage');
try {
const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' });
if (r.ok) {
const { url } = await r.json();
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
}
} finally {
setBusy(null);
}
};
const handleDisconnect = async () => {
setBusy('disconnect');
try {
await dispatch(disconnectSubscription()).unwrap();
await refresh();
} finally {
setBusy(null);
}
};
// Loading state — don't flash a CTA that disappears on first fetch.
if (!status) return null;
const isConnected = !!status.connected;
const usage = status.usage;
// Pool utilization is live data from Claude's own /api/oauth/usage endpoint
// — a 0-100 percentage for the current 5h window of the subscription we're
// routing this user through.
const pct = Math.max(0, Math.min(100, Math.round(usage?.utilization ?? 0)));
const windowEndsAt = usage?.window_ends_at;
const expiresLabel = (() => {
if (!status.expires) return null;
try {
const d = new Date(status.expires);
return d.toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
});
} catch {
return null;
}
})();
const planLabel = (() => {
if (!status.plan) return 'Pro';
return status.plan
.replace(/_/g, '+')
.replace(/\b\w/g, (s) => s.toUpperCase());
})();
return (
<Box
sx={{
p: 2.5,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${isConnected ? c.accent.primary : c.border.subtle}`,
bgcolor: isConnected ? `${c.accent.primary}08` : c.bg.surface,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: isConnected ? 1.5 : 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>
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>
)}
{!isConnected && (
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}15` }}>
<Typography sx={{ fontSize: '0.65rem', color: c.accent.primary, fontWeight: 600 }}>
RECOMMENDED
</Typography>
</Box>
)}
</Box>
</Box>
{isConnected ? (
<>
{/* Usage bar — percentage only, no raw counts */}
<Box sx={{ mb: 1.2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500 }}>
Current usage
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
{pct}% used
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 6,
borderRadius: 999,
bgcolor: `${c.accent.primary}15`,
'& .MuiLinearProgress-bar': {
bgcolor: pct >= 90 ? c.status.warning : pct >= 70 ? c.status.info : c.accent.primary,
borderRadius: 999,
},
}}
/>
{windowEndsAt && (
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted, mt: 0.4 }}>
Resets {(() => {
const diff = windowEndsAt - Date.now();
if (diff <= 0) return 'soon';
const hrs = Math.floor(diff / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
if (hrs > 0) return `in ${hrs} hr ${mins} min`;
return `in ${mins} min`;
})()}
</Typography>
)}
</Box>
{expiresLabel && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mb: 1.5 }}>
{status.status === 'canceled' ? 'Expires' : 'Renews'} on {expiresLabel}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={handleManage}
disabled={busy !== null}
size="small"
variant="contained"
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
>
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
</Button>
<Button
onClick={handleDisconnect}
disabled={busy !== null}
size="small"
variant="text"
sx={{ textTransform: 'none', fontSize: '0.78rem', color: c.text.muted }}
>
{busy === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
</Button>
</Box>
</>
) : (
<>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
One subscription, no Claude account needed. We handle everything behind the scenes.
</Typography>
<Button
onClick={handleSubscribe}
variant="contained"
size="small"
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
>
Subscribe to OpenSwarm Pro
</Button>
</>
)}
</Box>
);
};
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -1462,9 +1692,20 @@ const Settings: React.FC = () => {
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
{/* ── OPENSWARM PRO (managed) ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
Use Your Existing Subscriptions
One Subscription, No Setup
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku.
</Typography>
<OpenSwarmProCard />
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
+67
View File
@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { trackEvent } from '@/shared/analytics';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
// payload and dispatches activateSubscription so the backend validates and
// persists the bearer.
//
// Safe no-op in web/browser contexts where window.openswarm isn't defined.
export function useDeepLink(): void {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.onAuthUrl) return;
const unsubscribe = api.onAuthUrl((rawUrl: string) => {
try {
// openswarm://auth?token=... (host = "auth", search carries fields)
const url = new URL(rawUrl);
if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') {
console.warn('[deep-link] Unknown openswarm:// host:', url.host);
return;
}
const token = url.searchParams.get('token');
if (!token) {
console.warn('[deep-link] Missing token in', rawUrl);
return;
}
const plan = url.searchParams.get('plan');
const expires = url.searchParams.get('expires');
trackEvent('subscription.deep_link_received', {
plan: plan ?? 'unknown',
});
dispatch(
activateSubscription({
token,
plan,
expires,
}),
)
.unwrap()
.then((res) => {
trackEvent('subscription.activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
dispatch(fetchModels());
})
.catch((err) => {
console.error('[deep-link] Activation failed:', err);
trackEvent('subscription.activation_failed', {
message: String(err).slice(0, 120),
});
});
} catch (e) {
console.error('[deep-link] Failed to parse URL', rawUrl, e);
}
});
return unsubscribe;
}, [dispatch]);
}
@@ -27,6 +27,13 @@ export interface CustomProvider {
models: Array<{ value: string; label: string; context_window?: number }>;
}
export interface SubscriptionUsage {
requests_in_window: number;
plan_limit: number;
window_hours: number;
window_ends_at: number; // unix ms
}
export interface AppSettings {
default_system_prompt: string | null;
default_folder: string | null;
@@ -46,6 +53,20 @@ export interface AppSettings {
expand_new_chats_in_dashboard: boolean;
auto_reveal_sub_agents: boolean;
dev_mode: boolean;
// Optional managed-subscription state (surfaces only when user has
// subscribed via the cloud). Mirrors AppSettings on the backend.
connection_mode?: 'own_key' | 'openswarm-pro';
openswarm_bearer_token?: string | null;
openswarm_proxy_url?: string | null;
openswarm_subscription_plan?: string | null;
openswarm_subscription_expires?: string | null;
openswarm_usage_cached?: SubscriptionUsage | null;
}
export interface ActivateSubscriptionPayload {
token: string;
plan?: string | null;
expires?: string | null;
}
export interface BrowseResult {
@@ -123,6 +144,36 @@ export const browseDirectories = createAsyncThunk(
}
);
// POST /api/subscription/activate — called after the desktop catches an
// openswarm://auth?token=... deep link. Validates + persists on the backend,
// then refreshes settings so the Settings UI flips to "Pro" mode.
export const activateSubscription = createAsyncThunk(
'settings/activateSubscription',
async (payload: ActivateSubscriptionPayload, { dispatch }) => {
const res = await fetch(`${API_BASE}/subscription/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.text()) || 'Activation failed');
// Pull the fresh settings so UI reflects connection_mode + plan.
await dispatch(fetchSettings());
return (await res.json()) as { ok: boolean; plan: string };
}
);
// POST /api/subscription/disconnect — clears bearer + reverts to own_key.
// Doesn't cancel the Stripe subscription (that's the Portal).
export const disconnectSubscription = createAsyncThunk(
'settings/disconnectSubscription',
async (_: void, { dispatch }) => {
const res = await fetch(`${API_BASE}/subscription/disconnect`, { method: 'POST' });
if (!res.ok) throw new Error('Disconnect failed');
await dispatch(fetchSettings());
return true;
}
);
const settingsSlice = createSlice({
name: 'settings',
initialState,
+3
View File
@@ -45,6 +45,9 @@ declare global {
onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
onUpdateError: (cb: (message: string) => void) => () => void;
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
openExternal: (url: string) => Promise<void>;
// Deep-link listener — fires when OS opens the app with openswarm://... URL.
onAuthUrl?: (cb: (url: string) => void) => () => void;
}
interface Window {
File diff suppressed because one or more lines are too long