[eric] zero-friction subscription access via 9Router proxy

- 9Router auto-starts silently in background on app launch
- Subscription proxy endpoints: /subscriptions/status, connect, poll, models
- Per-provider subscription cards in Settings → Models (Claude, ChatGPT, Copilot, Gemini)
- Click "Connect" → browser opens for OAuth → poll until done → "Connected ✓"
- No terminal, no commands, no copy-paste — just click + browser sign-in
- 9Router manages OAuth tokens, auto-refresh, quota tracking
- Removed old NineRouterSetup terminal command UI
- Removed standalone CopilotAuthButton (merged into SubscriptionCards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-21 20:49:43 -07:00
co-authored by Claude Opus 4.6
parent b9b044a2e3
commit 50b28f7080
4 changed files with 342 additions and 94 deletions
+60
View File
@@ -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}
+14
View File
@@ -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")
+147
View File
@@ -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 []
+121 -94
View File
@@ -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 (
<Box sx={{ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${connected ? c.status.success + '30' : c.border.subtle}`, bgcolor: connected ? `${c.status.success}04` : 'transparent' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: connected ? c.status.success : c.border.medium, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{provider.desc}</Typography>
</Box>
</Box>
{connected ? (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error } }}>
Disconnect
</Typography>
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : (
<Button onClick={onConnect} disabled={connecting} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary } }}>
{connecting ? 'Waiting...' : 'Connect'}
</Button>
)}
</Box>
</Box>
);
};
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<any>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.status.success, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
9Router detected and connected
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1 }}>
Starting subscription service...
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
</Typography>
</Box>
);
}
return (
<Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.primary }}>
Quick setup (2 minutes):
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>
1. Open any terminal app on your computer (Terminal, Command Prompt, etc.)
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>
2. Copy and paste this command, then press Enter:
</Typography>
<Box
sx={{ bgcolor: 'rgba(255,255,255,0.04)', borderRadius: 1, px: 1.5, py: 0.75, fontFamily: 'monospace', fontSize: '0.72rem', color: c.accent.primary, cursor: 'pointer', '&:hover': { bgcolor: 'rgba(255,255,255,0.07)' } }}
onClick={() => navigator.clipboard.writeText('npx 9router')}
>
npx 9router <span style={{ fontSize: '0.6rem', color: c.text.ghost, marginLeft: 8 }}>click to copy</span>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>
3. A dashboard will open in your browser. Sign in to your AI subscriptions there (Claude, ChatGPT, Gemini, etc.)
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>
4. Come back here and click "Check Connection" that's it!
</Typography>
</Box>
<Button
onClick={checkConnection}
variant="outlined"
size="small"
disabled={checking}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: c.text.primary,
borderColor: c.border.medium,
'&:hover': { borderColor: c.status.success, color: c.status.success },
}}
>
{checking ? 'Checking...' : 'Check Connection'}
</Button>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
<SubscriptionCard
key={p.id}
provider={p}
connected={isConnected(p.id)}
onConnect={() => handleConnect(p.id)}
onDisconnect={() => handleDisconnect(p.id)}
connecting={connecting === p.id}
userCode={connecting === p.id ? userCode : undefined}
/>
))}
</Box>
);
};
@@ -1148,35 +1199,11 @@ const Settings: React.FC = () => {
Use Your Existing Subscriptions
</Typography>
{/* 9Router — use subscriptions */}
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: `${c.status.success}06`, border: `1px solid ${c.status.success}20` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ ...labelSx, mb: 0 }}>9Router</Typography>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 1, py: 0.25, borderRadius: '4px' }}>
FREE USE YOUR SUBSCRIPTIONS
</Typography>
</Box>
<Typography sx={{ ...descSx, mb: 1.5 }}>
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.
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
</Typography>
<NineRouterSetup />
</Box>
{/* GitHub Copilot */}
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: `${c.accent.primary}06`, border: `1px solid ${c.accent.primary}20` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ ...labelSx, mb: 0 }}>GitHub Copilot</Typography>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 1, py: 0.25, borderRadius: '4px' }}>
USE SUBSCRIPTION
</Typography>
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>
Sign in with GitHub to use Claude, GPT, and other models through your Copilot subscription.
</Typography>
<CopilotAuthButton />
</Box>
<SubscriptionCards />
{/* ── API KEYS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>