[eric] 9Router subscription proxy, GitHub Copilot auth, Settings restructure

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) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-21 20:46:26 -07:00
co-authored by Claude Opus 4.6
parent b6f45e8412
commit b9b044a2e3
7 changed files with 765 additions and 27 deletions
+93
View File
@@ -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}
+175
View File
@@ -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
+121
View File
@@ -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")
+79 -6
View File
@@ -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
+35 -13
View File
@@ -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.")
+8
View File
@@ -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
+254 -8
View File
@@ -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 (
<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 }}>
Connected{username ? ` as @${username}` : ''}
</Typography>
<Typography
onClick={disconnect}
sx={{ fontSize: '0.72rem', color: c.text.tertiary, cursor: 'pointer', ml: 'auto', '&:hover': { color: c.status.error } }}
>
Disconnect
</Typography>
</Box>
);
}
if (status === 'waiting') {
return (
<Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, mb: 0.5 }}>
Enter code <strong style={{ fontFamily: 'monospace', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{userCode}</strong> at github.com/login/device
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary }}>Waiting for authorization...</Typography>
</Box>
);
}
return (
<Box>
<Button
onClick={startAuth}
variant="outlined"
size="small"
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.text.primary,
borderColor: c.border.medium,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
Sign in with GitHub
</Button>
{error && <Typography sx={{ fontSize: '0.7rem', color: c.status.error, mt: 0.5 }}>{error}</Typography>}
</Box>
);
};
// ── 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 (
<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
</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>
);
};
// ── 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 = () => {
</Box>
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
<Typography sx={descSx}>
Connect your AI model providers. Each key is stored locally on your device.
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
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>
<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>
{/* ── API KEYS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
{/* OpenRouter — recommended */}
@@ -991,8 +1222,13 @@ const Settings: React.FC = () => {
{/* Anthropic */}
<Box>
<Typography sx={labelSx}>Anthropic</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku direct API access.</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Anthropic</Typography>
{form.anthropic_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
@@ -1026,8 +1262,13 @@ const Settings: React.FC = () => {
{/* OpenAI */}
<Box>
<Typography sx={labelSx}>OpenAI</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>GPT-5.4, o3, o4-mini direct API access.</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>OpenAI</Typography>
{(form as any).openai_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>GPT-5.4, o3, o4-mini.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="password"
@@ -1052,8 +1293,13 @@ const Settings: React.FC = () => {
{/* Google */}
<Box>
<Typography sx={labelSx}>Google</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>Gemini 2.5 Pro and Flash direct API access.</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Google</Typography>
{(form as any).google_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>Gemini 2.5 Pro and Flash.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="password"