From b9b044a2e3ce38bb083adb5034c7a26616590bda Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 21 Mar 2026 20:46:26 -0700 Subject: [PATCH] [eric] 9Router subscription proxy, GitHub Copilot auth, Settings restructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription access (WIP): - 9Router integration: auto-detects if running, routes through user's subscriptions - Fallback routing: no API key → check 9Router → use subscription - Model ID mapping for 9Router (cc/ prefix for Claude Code models) - GitHub Copilot OAuth device flow (copilot_auth.py, providers/copilot.py) - Copilot auth endpoints: start-auth, poll-auth, models, disconnect Settings restructure: - 4 tabs: General, Models, Usage, Commands - Models tab: Subscriptions section (9Router + Copilot) + API Keys section - Per-provider "CONNECTED" badges - Subscription token fields removed (Anthropic banned OAuth in third-party apps) - 9Router auto-detection with "Check Connection" button Provider routing: - create_provider() checks subscription tokens → API keys → 9Router fallback - validate_credentials() allows through if 9Router is running - 9Router model ID mapping (sonnet → cc/claude-sonnet-4-6) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/agents/agents.py | 93 +++++++ backend/apps/agents/copilot_auth.py | 175 +++++++++++++ backend/apps/agents/providers/copilot.py | 121 +++++++++ backend/apps/agents/providers/registry.py | 85 +++++- backend/apps/settings/credentials.py | 48 +++- backend/apps/settings/models.py | 8 + frontend/src/app/pages/Settings/Settings.tsx | 262 ++++++++++++++++++- 7 files changed, 765 insertions(+), 27 deletions(-) create mode 100644 backend/apps/agents/copilot_auth.py create mode 100644 backend/apps/agents/providers/copilot.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index a1e69dcf..36f9c41e 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -188,3 +188,96 @@ async def list_models(): settings = load_settings() return {"models": get_available_models(settings)} + +# ── GitHub Copilot Auth ── + +@agents.router.post("/copilot/start-auth") +async def copilot_start_auth(): + """Start GitHub device flow for Copilot auth.""" + from backend.apps.agents.copilot_auth import start_device_flow + try: + result = await start_device_flow() + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@agents.router.post("/copilot/poll-auth") +async def copilot_poll_auth(body: dict): + """Poll for GitHub auth completion. Returns token on success.""" + from backend.apps.agents.copilot_auth import poll_for_token, exchange_for_copilot_token, get_github_username, list_copilot_models + from backend.apps.settings.settings import load_settings, _save_settings + + device_code = body.get("device_code", "") + if not device_code: + raise HTTPException(status_code=400, detail="device_code required") + + try: + github_token = await poll_for_token(device_code) + if github_token is None: + return {"status": "pending"} + + # Got GitHub token — exchange for Copilot token + copilot_result = await exchange_for_copilot_token(github_token) + username = await get_github_username(github_token) + + # Fetch available models + models = await list_copilot_models(copilot_result["token"]) + + # Save to settings + settings = load_settings() + settings.copilot_github_token = github_token + settings.copilot_token = copilot_result["token"] + settings.copilot_token_expires = copilot_result["expires_at"] + _save_settings(settings) + + return { + "status": "connected", + "username": username, + "models": models, + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@agents.router.get("/copilot/models") +async def copilot_models(): + """List models available through Copilot.""" + from backend.apps.agents.copilot_auth import list_copilot_models, get_copilot_token + from backend.apps.settings.settings import load_settings, _save_settings + + settings = load_settings() + github_token = getattr(settings, "copilot_github_token", None) + if not github_token: + return {"models": []} + + try: + result = await get_copilot_token( + github_token, + getattr(settings, "copilot_token", None), + getattr(settings, "copilot_token_expires", None), + ) + settings.copilot_token = result["token"] + settings.copilot_token_expires = result["expires_at"] + _save_settings(settings) + + models = await list_copilot_models(result["token"]) + return {"models": models} + except Exception as e: + return {"models": [], "error": str(e)} + + +@agents.router.post("/copilot/disconnect") +async def copilot_disconnect(): + """Clear Copilot tokens.""" + from backend.apps.settings.settings import load_settings, _save_settings + + settings = load_settings() + settings.copilot_github_token = None + settings.copilot_token = None + settings.copilot_token_expires = None + _save_settings(settings) + return {"ok": True} + diff --git a/backend/apps/agents/copilot_auth.py b/backend/apps/agents/copilot_auth.py new file mode 100644 index 00000000..d7e0d472 --- /dev/null +++ b/backend/apps/agents/copilot_auth.py @@ -0,0 +1,175 @@ +"""GitHub Copilot device flow OAuth + token management. + +Handles the full flow: +1. Start device flow → get user_code for user to enter at github.com/login/device +2. Poll until user authorizes → get GitHub access token +3. Exchange GitHub token → Copilot JWT (expires every ~30min) +4. Auto-refresh Copilot token before expiry +""" + +import logging +import time + +import httpx + +logger = logging.getLogger(__name__) + +CLIENT_ID = "Iv1.b507a08c87ecfe98" # Copilot's public OAuth app ID +DEVICE_CODE_URL = "https://github.com/login/device/code" +TOKEN_URL = "https://github.com/login/oauth/access_token" +COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" +COPILOT_API_BASE = "https://api.githubcopilot.com" + +HEADERS = { + "accept": "application/json", + "content-type": "application/json", + "editor-version": "vscode/1.100.0", + "editor-plugin-version": "copilot-chat/0.30.0", + "user-agent": "GithubCopilot/1.200.0", +} + + +async def start_device_flow() -> dict: + """Start GitHub device flow. + + Returns: {user_code, verification_uri, device_code, expires_in, interval} + """ + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + DEVICE_CODE_URL, + headers=HEADERS, + json={"client_id": CLIENT_ID, "scope": "read:user"}, + ) + resp.raise_for_status() + data = resp.json() + return { + "user_code": data["user_code"], + "verification_uri": data["verification_uri"], + "device_code": data["device_code"], + "expires_in": data.get("expires_in", 900), + "interval": data.get("interval", 5), + } + + +async def poll_for_token(device_code: str) -> str | None: + """Poll GitHub for token after user authorizes. + + Returns the GitHub access token (gho_xxx), or None if still pending. + Raises on error (expired, denied, etc.) + """ + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + TOKEN_URL, + headers=HEADERS, + json={ + "client_id": CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + data = resp.json() + + if "access_token" in data: + return data["access_token"] + + error = data.get("error", "") + if error == "authorization_pending": + return None # Still waiting + if error == "slow_down": + return None # Need to slow down polling + if error in ("expired_token", "access_denied"): + raise ValueError(f"GitHub auth failed: {error}") + + return None + + +async def exchange_for_copilot_token(github_token: str) -> dict: + """Exchange GitHub OAuth token for Copilot JWT. + + Returns: {token, expires_at} + """ + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + COPILOT_TOKEN_URL, + headers={ + **HEADERS, + "authorization": f"token {github_token}", + }, + ) + if resp.status_code == 401: + raise ValueError("GitHub token invalid or expired. Please re-authenticate.") + if resp.status_code == 403: + raise ValueError("No Copilot subscription found for this GitHub account.") + resp.raise_for_status() + + data = resp.json() + token = data.get("token", "") + + # Extract expiry from token (format: tid=xxx;exp=1234567890;...) + expires_at = time.time() + 25 * 60 # Default 25 min + if "exp=" in token: + try: + for pair in token.split(";"): + if pair.strip().startswith("exp="): + expires_at = int(pair.strip().split("=")[1]) + break + except (ValueError, IndexError): + pass + + return {"token": token, "expires_at": expires_at} + + +async def get_copilot_token(github_token: str, current_token: str | None = None, expires_at: float | None = None) -> dict: + """Get a valid Copilot token, refreshing if needed. + + Returns: {token, expires_at} + """ + # If current token is still valid (with 2 min buffer), return it + if current_token and expires_at and time.time() < expires_at - 120: + return {"token": current_token, "expires_at": expires_at} + + # Otherwise refresh + return await exchange_for_copilot_token(github_token) + + +async def list_copilot_models(copilot_token: str) -> list[dict]: + """Fetch available models from Copilot API.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{COPILOT_API_BASE}/models", + headers={ + "authorization": f"Bearer {copilot_token}", + "copilot-integration-id": "vscode-chat", + **HEADERS, + }, + ) + if resp.status_code != 200: + logger.warning(f"Failed to list Copilot models: {resp.status_code}") + return [] + + data = resp.json() + models = data.get("data", data.get("models", [])) + return [ + { + "value": m.get("id", m.get("name", "")), + "label": m.get("name", m.get("id", "")), + "context_window": m.get("context_window", 128_000), + } + for m in models + if isinstance(m, dict) + ] + + +async def get_github_username(github_token: str) -> str | None: + """Get the GitHub username for display.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + "https://api.github.com/user", + headers={"authorization": f"token {github_token}", "accept": "application/json"}, + ) + if resp.status_code == 200: + return resp.json().get("login") + except Exception: + pass + return None diff --git a/backend/apps/agents/providers/copilot.py b/backend/apps/agents/providers/copilot.py new file mode 100644 index 00000000..84e0a0ff --- /dev/null +++ b/backend/apps/agents/providers/copilot.py @@ -0,0 +1,121 @@ +"""GitHub Copilot provider — routes through Copilot's OpenAI-compatible API. + +Uses the user's GitHub Copilot subscription to access Claude, GPT, and other models. +Extends OpenAICompatProvider since Copilot's API speaks the OpenAI format. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, AsyncIterator + +from openai import AsyncOpenAI + +from backend.apps.agents.providers.base import ( + BaseProvider, ProviderMessage, StreamEvent, ToolSchema, ModelResponse, +) +from backend.apps.agents.providers.openai_compat import OpenAICompatProvider + +logger = logging.getLogger(__name__) + +COPILOT_API_BASE = "https://api.githubcopilot.com" + + +class CopilotProvider(OpenAICompatProvider): + """Provider that routes through GitHub Copilot's API.""" + + def __init__(self, copilot_token: str): + # Initialize OpenAI client pointing at Copilot's API + self.client = AsyncOpenAI( + api_key=copilot_token, + base_url=COPILOT_API_BASE, + ) + # Store token for header injection + self._copilot_token = copilot_token + + def get_model_id(self, short_name: str) -> str: + # Copilot uses same model IDs — pass through + return short_name + + async def stream_message( + self, + model: str, + system: str | None, + messages: list[ProviderMessage], + tools: list[ToolSchema], + max_tokens: int = 8192, + ) -> AsyncIterator[StreamEvent]: + """Stream with Copilot-specific headers.""" + kwargs: dict[str, Any] = { + "model": self.get_model_id(model), + "max_tokens": max_tokens, + "messages": self._build_messages(system, messages), + "stream": True, + "extra_headers": { + "copilot-integration-id": "vscode-chat", + }, + } + if tools: + kwargs["tools"] = [self.clean_tool_schema(t) for t in tools] + + stream = await self.client.chat.completions.create(**kwargs) + + # Reuse parent's stream parsing logic + text_started = False + text_index = 0 + tool_indices: dict[int, dict] = {} + next_block_index = 0 + + from uuid import uuid4 + + async for chunk in stream: + if not chunk.choices: + continue + + delta = chunk.choices[0].delta + finish_reason = chunk.choices[0].finish_reason + + if delta.content is not None: + if not text_started: + text_started = True + text_index = next_block_index + next_block_index += 1 + yield StreamEvent(type="content_block_start", index=text_index, block_type="text") + yield StreamEvent(type="content_block_delta", index=text_index, delta_type="text_delta", text=delta.content) + + if delta.tool_calls: + for tc_delta in delta.tool_calls: + tc_idx = tc_delta.index + if tc_idx not in tool_indices: + if text_started: + yield StreamEvent(type="content_block_stop", index=text_index) + text_started = False + block_idx = next_block_index + next_block_index += 1 + tool_indices[tc_idx] = { + "block_index": block_idx, + "id": tc_delta.id or uuid4().hex, + "name": tc_delta.function.name if tc_delta.function else "", + "json_buf": "", + } + yield StreamEvent( + type="content_block_start", index=block_idx, block_type="tool_use", + tool_name=tool_indices[tc_idx]["name"], tool_id=tool_indices[tc_idx]["id"], + ) + info = tool_indices[tc_idx] + if tc_delta.function and tc_delta.function.name: + info["name"] = tc_delta.function.name + if tc_delta.function and tc_delta.function.arguments: + info["json_buf"] += tc_delta.function.arguments + yield StreamEvent( + type="content_block_delta", index=info["block_index"], + delta_type="input_json_delta", text=tc_delta.function.arguments, + ) + + if finish_reason is not None: + if text_started: + yield StreamEvent(type="content_block_stop", index=text_index) + for info in tool_indices.values(): + yield StreamEvent(type="content_block_stop", index=info["block_index"]) + yield StreamEvent(type="message_stop") diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index f342cf05..2246a836 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -70,6 +70,25 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +_9router_cache: dict = {"available": None, "checked_at": 0} + + +def _is_9router_available() -> bool: + """Check if 9Router is running on localhost:20128. Caches for 30 seconds.""" + import time as _time + now = _time.time() + if _9router_cache["available"] is not None and now - _9router_cache["checked_at"] < 30: + return _9router_cache["available"] + try: + import httpx + r = httpx.get("http://localhost:20128/v1/models", timeout=2.0) + available = r.status_code == 200 + except Exception: + available = False + _9router_cache["available"] = available + _9router_cache["checked_at"] = now + return available + # --------------------------------------------------------------------------- # Provider factory @@ -91,6 +110,37 @@ def create_provider( """ api_type = _get_api_type(provider_name) + # Check for 9Router first + if provider_name in ("9Router", "9router"): + from backend.apps.agents.providers.openai_compat import OpenAICompatProvider + return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") + + # Check for GitHub Copilot + if provider_name in ("GitHub Copilot", "copilot"): + from backend.apps.agents.providers.copilot import CopilotProvider + copilot_token = getattr(settings, "copilot_token", None) + if not copilot_token: + raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.") + # Auto-refresh if expired + import time as _time + expires = getattr(settings, "copilot_token_expires", None) + if expires and _time.time() > expires - 120: + github_token = getattr(settings, "copilot_github_token", None) + if github_token: + import asyncio + from backend.apps.agents.copilot_auth import exchange_for_copilot_token + try: + loop = asyncio.get_event_loop() + result = loop.run_until_complete(exchange_for_copilot_token(github_token)) + copilot_token = result["token"] + settings.copilot_token = copilot_token + settings.copilot_token_expires = result["expires_at"] + from backend.apps.settings.settings import _save_settings + _save_settings(settings) + except Exception as e: + logger.warning(f"Copilot token refresh failed: {e}") + return CopilotProvider(copilot_token=copilot_token) + if api_type == "anthropic": from backend.apps.agents.providers.anthropic import AnthropicProvider if getattr(settings, "connection_mode", "own_key") == "managed": @@ -98,18 +148,41 @@ def create_provider( auth_token=getattr(settings, "openswarm_auth_token", None), base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai", ) - return AnthropicProvider(api_key=settings.anthropic_api_key) + if settings.anthropic_api_key: + return AnthropicProvider(api_key=settings.anthropic_api_key) + # No API key — try 9Router as fallback + if _is_9router_available(): + from backend.apps.agents.providers.openai_compat import OpenAICompatProvider + provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") + # Override get_model_id to map our short names to 9Router's cc/ prefixed IDs + _original_get_model = provider.get_model_id + _9r_model_map = { + "sonnet": "cc/claude-sonnet-4-6", + "opus": "cc/claude-opus-4-6", + "haiku": "cc/claude-haiku-4-5-20251001", + } + provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name) + return provider + raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.") if api_type == "openai": from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - return OpenAICompatProvider( - api_key=settings.openai_api_key or "", - base_url="https://api.openai.com/v1", - ) + if settings.openai_api_key: + return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1") + # No API key — try 9Router as fallback + if _is_9router_available(): + return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") + raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.") if api_type == "gemini": from backend.apps.agents.providers.gemini import GeminiProvider - return GeminiProvider(api_key=settings.google_api_key or "") + if settings.google_api_key: + return GeminiProvider(api_key=settings.google_api_key) + # No API key — try 9Router as fallback + if _is_9router_available(): + from backend.apps.agents.providers.openai_compat import OpenAICompatProvider + return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") + raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.") if api_type == "openrouter": from backend.apps.agents.providers.openai_compat import OpenAICompatProvider diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py index 985ac117..5c1d4917 100644 --- a/backend/apps/settings/credentials.py +++ b/backend/apps/settings/credentials.py @@ -15,25 +15,47 @@ if TYPE_CHECKING: OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai" +def _check_9router() -> bool: + """Check if 9Router is running locally.""" + try: + import httpx + r = httpx.get("http://localhost:20128/v1/models", timeout=2.0) + return r.status_code == 200 + except Exception: + return False + + def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None: - """Raise ValueError if credentials are missing for the given provider.""" + """Raise ValueError if credentials are missing for the given provider. + + Allows through if 9Router is running as a fallback. + """ + # 9Router or GitHub Copilot providers don't need traditional credentials + if provider in ("9Router", "9router", "GitHub Copilot", "copilot"): + return + if provider == "anthropic": if getattr(settings, "connection_mode", "own_key") == "managed": if not getattr(settings, "openswarm_auth_token", None): - raise ValueError( - "Open Swarm account not connected. Sign in via Settings → API." - ) - else: - if not settings.anthropic_api_key: - raise ValueError( - "Anthropic API key not configured. Set it in Settings." - ) + raise ValueError("Open Swarm account not connected. Sign in via Settings → API.") + return + if settings.anthropic_api_key: + return + if _check_9router(): + return # 9Router will handle it + raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.") elif provider == "openai": - if not settings.openai_api_key: - raise ValueError("OpenAI API key not configured. Set it in Settings.") + if settings.openai_api_key: + return + if _check_9router(): + return + raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.") elif provider == "gemini": - if not getattr(settings, "google_api_key", None): - raise ValueError("Google API key not configured. Set it in Settings.") + if getattr(settings, "google_api_key", None): + return + if _check_9router(): + return + raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.") elif provider == "openrouter": if not getattr(settings, "openrouter_api_key", None): raise ValueError("OpenRouter API key not configured. Set it in Settings.") diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index b6760d8c..968857ca 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -37,6 +37,14 @@ class AppSettings(BaseModel): expand_new_chats_in_dashboard: bool = False auto_reveal_sub_agents: bool = True dev_mode: bool = False + # Subscription tokens (from CLI tools — alternative to API keys) + claude_subscription_token: Optional[str] = None + openai_subscription_token: Optional[str] = None + gemini_subscription_token: Optional[str] = None + # GitHub Copilot + copilot_github_token: Optional[str] = None + copilot_token: Optional[str] = None + copilot_token_expires: Optional[float] = None # Analytics: opted in by default, user can toggle off analytics_opt_in: bool = True installation_id: Optional[str] = None diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index a888cacb..15671ffe 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -48,6 +48,196 @@ import DirectoryBrowser from '@/app/components/DirectoryBrowser'; import { CommandsContent } from '@/app/pages/Commands/Commands'; import { API_BASE } from '@/shared/config'; +// ── Copilot Auth Button ── +const CopilotAuthButton: React.FC = () => { + const c = useClaudeTokens(); + const [status, setStatus] = useState<'idle' | 'waiting' | 'connected' | 'error'>('idle'); + const [userCode, setUserCode] = useState(''); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + + // Check if already connected + useEffect(() => { + fetch(`${API_BASE}/agents/copilot/models`) + .then(r => r.json()) + .then(d => { + if (d.models && d.models.length > 0) setStatus('connected'); + }) + .catch(() => {}); + }, []); + + const startAuth = async () => { + setStatus('waiting'); + setError(''); + try { + const resp = await fetch(`${API_BASE}/agents/copilot/start-auth`, { method: 'POST' }); + const data = await resp.json(); + setUserCode(data.user_code); + window.open(data.verification_uri, '_blank'); + + // Poll for completion + const deviceCode = data.device_code; + const poll = setInterval(async () => { + try { + const r = await fetch(`${API_BASE}/agents/copilot/poll-auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }), + }); + const d = await r.json(); + if (d.status === 'connected') { + clearInterval(poll); + setStatus('connected'); + setUsername(d.username || ''); + } + } catch {} + }, 5000); + + // Timeout after 5 minutes + setTimeout(() => { clearInterval(poll); if (status === 'waiting') { setStatus('error'); setError('Auth timed out'); } }, 300000); + } catch (e: any) { + setStatus('error'); + setError(e.message || 'Failed to start auth'); + } + }; + + const disconnect = async () => { + await fetch(`${API_BASE}/agents/copilot/disconnect`, { method: 'POST' }); + setStatus('idle'); + setUsername(''); + }; + + if (status === 'connected') { + return ( + + + + Connected{username ? ` as @${username}` : ''} + + + Disconnect + + + ); + } + + if (status === 'waiting') { + return ( + + + Enter code {userCode} at github.com/login/device + + Waiting for authorization... + + ); + } + + return ( + + + {error && {error}} + + ); +}; + +// ── 9Router Setup ── +const NineRouterSetup: React.FC = () => { + const c = useClaudeTokens(); + const [checking, setChecking] = useState(false); + const [connected, setConnected] = useState(false); + + 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 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); + }; + + if (connected) { + return ( + + + + 9Router detected and connected + + + ); + } + + 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! + + + + + ); +}; + // ── Pixel Bar ── const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E']; const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD']; @@ -952,8 +1142,49 @@ const Settings: React.FC = () => { ) : activeTab === 'models' ? ( - - Connect your AI model providers. Each key is stored locally on your device. + + {/* ── USE EXISTING SUBSCRIPTIONS ── */} + + 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. + + + + + + {/* GitHub Copilot */} + + + GitHub Copilot + + USE SUBSCRIPTION + + + + Sign in with GitHub to use Claude, GPT, and other models through your Copilot subscription. + + + + + {/* ── API KEYS ── */} + + Or Connect With API Keys + + + + Pay per use. Each key is stored locally on your device. {/* OpenRouter — recommended */} @@ -991,8 +1222,13 @@ const Settings: React.FC = () => { {/* Anthropic */} - Anthropic - Claude Sonnet, Opus, Haiku — direct API access. + + Anthropic + {form.anthropic_api_key ? ( + CONNECTED + ) : null} + + Claude Sonnet, Opus, Haiku. { {/* OpenAI */} - OpenAI - GPT-5.4, o3, o4-mini — direct API access. + + OpenAI + {(form as any).openai_api_key ? ( + CONNECTED + ) : null} + + GPT-5.4, o3, o4-mini. { {/* Google */} - Google - Gemini 2.5 Pro and Flash — direct API access. + + Google + {(form as any).google_api_key ? ( + CONNECTED + ) : null} + + Gemini 2.5 Pro and Flash.