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 ── */}