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