diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py
index 36f9c41e..e11b10ed 100644
--- a/backend/apps/agents/agents.py
+++ b/backend/apps/agents/agents.py
@@ -281,3 +281,63 @@ async def copilot_disconnect():
_save_settings(settings)
return {"ok": True}
+
+# ── Subscription Management (via 9Router) ──
+
+@agents.router.get("/subscriptions/status")
+async def subscriptions_status():
+ """Check if 9Router is running and list connected providers."""
+ from backend.apps.nine_router import is_running, get_providers, get_models
+ if not is_running():
+ return {"running": False, "providers": [], "models": []}
+ providers = await get_providers()
+ models = await get_models()
+ return {"running": True, "providers": providers, "models": models}
+
+
+@agents.router.post("/subscriptions/connect")
+async def subscriptions_connect(body: dict):
+ """Start OAuth flow for a subscription provider."""
+ from backend.apps.nine_router import is_running, ensure_running, start_oauth
+ provider = body.get("provider", "")
+ if not provider:
+ raise HTTPException(status_code=400, detail="provider required")
+
+ if not is_running():
+ import asyncio
+ await ensure_running()
+ if not is_running():
+ raise HTTPException(status_code=503, detail="9Router not available. Please install Node.js.")
+
+ try:
+ result = await start_oauth(provider)
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@agents.router.post("/subscriptions/poll")
+async def subscriptions_poll(body: dict):
+ """Poll for OAuth completion."""
+ from backend.apps.nine_router import poll_oauth
+ provider = body.get("provider", "")
+ device_code = body.get("device_code", "")
+ if not provider or not device_code:
+ raise HTTPException(status_code=400, detail="provider and device_code required")
+
+ try:
+ result = await poll_oauth(provider, device_code)
+ return result
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@agents.router.get("/subscriptions/models")
+async def subscriptions_models():
+ """List all models available through connected subscriptions."""
+ from backend.apps.nine_router import is_running, get_models
+ if not is_running():
+ return {"models": []}
+ models = await get_models()
+ return {"models": models}
+
diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py
index 2540de1b..600ccbcd 100644
--- a/backend/apps/analytics/analytics.py
+++ b/backend/apps/analytics/analytics.py
@@ -51,8 +51,22 @@ async def analytics_lifespan():
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
+ # Auto-start 9Router for subscription access
+ try:
+ from backend.apps.nine_router import ensure_running as ensure_9router, stop as stop_9router
+ await ensure_9router()
+ except Exception as e:
+ logger.debug(f"9Router auto-start skipped: {e}")
+
yield
+ # Stop 9Router
+ try:
+ from backend.apps.nine_router import stop as stop_9router
+ stop_9router()
+ except Exception:
+ pass
+
shutdown_collector()
logger.info("PostHog analytics shut down")
diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py
new file mode 100644
index 00000000..daa6c1c4
--- /dev/null
+++ b/backend/apps/nine_router.py
@@ -0,0 +1,147 @@
+"""Auto-start and manage 9Router subprocess.
+
+9Router is a free AI subscription proxy that lets users connect their
+Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys.
+
+It runs silently in the background on port 20128 and exposes an
+OpenAI-compatible API at localhost:20128/v1.
+"""
+
+import asyncio
+import logging
+import os
+import shutil
+import subprocess
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+NINE_ROUTER_PORT = 20128
+NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
+NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
+NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
+
+_process: subprocess.Popen | None = None
+
+
+def is_running() -> bool:
+ """Check if 9Router is running."""
+ try:
+ r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
+ return r.status_code == 200
+ except Exception:
+ return False
+
+
+async def ensure_running():
+ """Start 9Router if not already running."""
+ global _process
+ if is_running():
+ logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
+ return
+
+ npx = shutil.which("npx")
+ if not npx:
+ logger.warning("npx not found — cannot auto-start 9Router. Install Node.js or run 9Router manually.")
+ return
+
+ logger.info("Starting 9Router on port %d...", NINE_ROUTER_PORT)
+ try:
+ env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
+ _process = subprocess.Popen(
+ [npx, "9router"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ env=env,
+ )
+
+ # Wait up to 15 seconds for it to start
+ for _ in range(30):
+ await asyncio.sleep(0.5)
+ if is_running():
+ logger.info("9Router started successfully")
+ return
+
+ logger.warning("9Router did not start within 15 seconds")
+ except Exception as e:
+ logger.warning(f"Failed to start 9Router: {e}")
+
+
+def stop():
+ """Stop the 9Router subprocess."""
+ global _process
+ if _process:
+ try:
+ _process.terminate()
+ _process.wait(timeout=5)
+ except Exception:
+ try:
+ _process.kill()
+ except Exception:
+ pass
+ _process = None
+ logger.info("9Router stopped")
+
+
+# ---------------------------------------------------------------------------
+# API proxy helpers — call 9Router's API from OpenSwarm
+# ---------------------------------------------------------------------------
+
+async def get_providers() -> list[dict]:
+ """Get all providers and their connection status from 9Router."""
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ r = await client.get(f"{NINE_ROUTER_API}/providers")
+ if r.status_code == 200:
+ return r.json()
+ except Exception as e:
+ logger.debug(f"9Router providers fetch failed: {e}")
+ return []
+
+
+async def start_oauth(provider: str) -> dict:
+ """Start OAuth device flow for a provider.
+
+ Returns: {user_code, verification_uri, device_code, ...}
+ """
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
+ r.raise_for_status()
+ return r.json()
+
+
+async def poll_oauth(provider: str, device_code: str) -> dict:
+ """Poll for OAuth completion.
+
+ Returns: {status: "pending"} or {status: "connected", connection: {...}}
+ """
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.post(
+ f"{NINE_ROUTER_API}/oauth/{provider}/poll",
+ json={"deviceCode": device_code},
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+async def get_models() -> list[dict]:
+ """Get all available models from 9Router."""
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ r = await client.get(f"{NINE_ROUTER_V1}/models")
+ if r.status_code == 200:
+ data = r.json()
+ models = data.get("data", [])
+ return [
+ {
+ "value": m.get("id", ""),
+ "label": m.get("id", "").split("/")[-1] if "/" in m.get("id", "") else m.get("id", ""),
+ "context_window": 200_000,
+ "provider": m.get("owned_by", "subscription"),
+ }
+ for m in models
+ ]
+ except Exception as e:
+ logger.debug(f"9Router models fetch failed: {e}")
+ return []
diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx
index 15671ffe..4f391b85 100644
--- a/frontend/src/app/pages/Settings/Settings.tsx
+++ b/frontend/src/app/pages/Settings/Settings.tsx
@@ -156,84 +156,135 @@ const CopilotAuthButton: React.FC = () => {
);
};
-// ── 9Router Setup ──
-const NineRouterSetup: React.FC = () => {
+// ── Subscription Provider Card ──
+const SUBSCRIPTION_PROVIDERS = [
+ { id: 'claude-code', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A' },
+ { id: 'openai-codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C' },
+ { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E' },
+ { id: 'gemini', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4' },
+];
+
+const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => {
const c = useClaudeTokens();
- const [checking, setChecking] = useState(false);
- const [connected, setConnected] = useState(false);
+ return (
+
+
+
+
+
+ {provider.name}
+ {provider.desc}
+
+
+ {connected ? (
+
+ Disconnect
+
+ ) : connecting && userCode ? (
+
+ Enter code:
+ {userCode}
+
+ ) : (
+
+ )}
+
+
+ );
+};
- useEffect(() => {
- // Auto-detect if 9Router is running
- fetch('http://localhost:20128/v1/models', { signal: AbortSignal.timeout(2000) })
- .then(r => r.ok ? r.json() : null)
- .then(d => { if (d?.data?.length > 0 || d?.length > 0) setConnected(true); })
- .catch(() => {});
- }, []);
+const SubscriptionCards: React.FC = () => {
+ const c = useClaudeTokens();
+ const [status, setStatus] = useState(null);
+ const [connecting, setConnecting] = useState(null);
+ const [userCode, setUserCode] = useState('');
+ const [pollTimer, setPollTimer] = useState(null);
- const checkConnection = async () => {
- setChecking(true);
- try {
- const r = await fetch('http://localhost:20128/v1/models', { signal: AbortSignal.timeout(3000) });
- if (r.ok) {
- const d = await r.json();
- if (d?.data?.length > 0 || d?.length > 0) { setConnected(true); setChecking(false); return; }
- }
- setConnected(false);
- } catch { setConnected(false); }
- setChecking(false);
+ const fetchStatus = () => {
+ fetch(`${API_BASE}/agents/subscriptions/status`)
+ .then(r => r.json())
+ .then(setStatus)
+ .catch(() => setStatus({ running: false, providers: [], models: [] }));
};
- if (connected) {
+ useEffect(() => { fetchStatus(); }, []);
+
+ const isConnected = (providerId: string) => {
+ if (!status?.providers) return false;
+ const providers = Array.isArray(status.providers) ? status.providers : [];
+ return providers.some((p: any) => p.provider === providerId && p.isActive);
+ };
+
+ const handleConnect = async (providerId: string) => {
+ setConnecting(providerId);
+ setUserCode('');
+ try {
+ const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId }),
+ });
+ const data = await r.json();
+ if (data.user_code || data.userCode) {
+ const code = data.user_code || data.userCode;
+ setUserCode(code);
+ if (data.verification_uri || data.verificationUri) {
+ window.open(data.verification_uri || data.verificationUri, '_blank');
+ }
+ // Start polling
+ const timer = setInterval(async () => {
+ try {
+ const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId, device_code: data.device_code || data.deviceCode }),
+ });
+ const pd = await pr.json();
+ if (pd.status === 'connected' || pd.success) {
+ clearInterval(timer);
+ setConnecting(null);
+ setUserCode('');
+ fetchStatus();
+ }
+ } catch {}
+ }, 5000);
+ setPollTimer(timer);
+ setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
+ }
+ } catch { setConnecting(null); }
+ };
+
+ const handleDisconnect = async (providerId: string) => {
+ // TODO: implement disconnect via 9Router API
+ fetchStatus();
+ };
+
+ if (!status?.running) {
return (
-
-
-
- 9Router detected and connected
+
+
+ Starting subscription service...
+
+
+ This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
);
}
return (
-
-
-
- Quick setup (2 minutes):
-
-
- 1. Open any terminal app on your computer (Terminal, Command Prompt, etc.)
-
-
- 2. Copy and paste this command, then press Enter:
-
- navigator.clipboard.writeText('npx 9router')}
- >
- npx 9router click to copy
-
-
- 3. A dashboard will open in your browser. Sign in to your AI subscriptions there (Claude, ChatGPT, Gemini, etc.)
-
-
- 4. Come back here and click "Check Connection" — that's it!
-
-
-
+
+ {SUBSCRIPTION_PROVIDERS.map(p => (
+ handleConnect(p.id)}
+ onDisconnect={() => handleDisconnect(p.id)}
+ connecting={connecting === p.id}
+ userCode={connecting === p.id ? userCode : undefined}
+ />
+ ))}
);
};
@@ -1148,35 +1199,11 @@ const Settings: React.FC = () => {
Use Your Existing Subscriptions
- {/* 9Router — use subscriptions */}
-
-
- 9Router
-
- FREE — USE YOUR SUBSCRIPTIONS
-
-
-
- Already paying for Claude, ChatGPT, or Gemini? Use those subscriptions here — no extra cost.
- 9Router is a free tool that connects your existing subscriptions to OpenSwarm.
-
+
+ Already paying for Claude, ChatGPT, or Gemini? Connect your subscription — no API key needed, no extra cost.
+
-
-
-
- {/* GitHub Copilot */}
-
-
- GitHub Copilot
-
- USE SUBSCRIPTION
-
-
-
- Sign in with GitHub to use Claude, GPT, and other models through your Copilot subscription.
-
-
-
+
{/* ── API KEYS ── */}