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.