mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[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:
co-authored by
Claude Opus 4.6
parent
b9b044a2e3
commit
50b28f7080
@@ -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}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 []
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user