Files
ciregenzandClaude Opus 4.6 b9b044a2e3 [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>
2026-03-21 20:46:26 -07:00

176 lines
5.8 KiB
Python

"""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