From 0a907e4bac54d9886fbfeecc2540a1a923038ab6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 21 Mar 2026 21:03:07 -0700 Subject: [PATCH] [eric] fix subscription provider IDs, handle both OAuth flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed provider IDs to match 9Router: claude, codex, github, gemini-cli - Handle authorization_code_pkce flow (Claude, Codex) — opens browser, polls status - Handle device_code flow (GitHub, Qwen, Kiro) — shows code, polls for completion - Pass code_verifier and extra_data through poll endpoint - Fix isConnected check for 9Router's {connections: [...]} response format - Auto-start 9Router in backend lifespan Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/agents/agents.py | 6 ++- backend/apps/nine_router.py | 49 ++++++++++++++++--- frontend/src/app/pages/Settings/Settings.tsx | 50 +++++++++++++++----- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index e11b10ed..fc24b045 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -326,7 +326,11 @@ async def subscriptions_poll(body: dict): raise HTTPException(status_code=400, detail="provider and device_code required") try: - result = await poll_oauth(provider, device_code) + result = await poll_oauth( + provider, device_code, + code_verifier=body.get("code_verifier"), + extra_data=body.get("extra_data"), + ) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index daa6c1c4..d4497957 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -101,25 +101,60 @@ async def get_providers() -> list[dict]: async def start_oauth(provider: str) -> dict: - """Start OAuth device flow for a provider. + """Start OAuth flow for a provider. - Returns: {user_code, verification_uri, device_code, ...} + For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code} + For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state} """ async with httpx.AsyncClient(timeout=15.0) as client: - r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code") + # Try device-code flow first + try: + r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code") + if r.status_code == 200: + data = r.json() + return { + "flow": "device_code", + "user_code": data.get("user_code", ""), + "verification_uri": data.get("verification_uri", data.get("verification_uri_complete", "")), + "device_code": data.get("device_code", ""), + "code_verifier": data.get("codeVerifier", ""), + "extra_data": {k: v for k, v in data.items() if k.startswith("_")}, + } + except Exception: + pass + + # Fall back to authorization_code flow + callback_url = f"http://localhost:{NINE_ROUTER_PORT}/api/oauth/{provider}/callback" + r = await client.get( + f"{NINE_ROUTER_API}/oauth/{provider}/authorize", + params={"redirect_uri": callback_url}, + ) r.raise_for_status() - return r.json() + data = r.json() + return { + "flow": "authorization_code", + "auth_url": data.get("authUrl", ""), + "code_verifier": data.get("codeVerifier", ""), + "state": data.get("state", ""), + "redirect_uri": callback_url, + } -async def poll_oauth(provider: str, device_code: str) -> dict: +async def poll_oauth(provider: str, device_code: str, code_verifier: str | None = None, extra_data: dict | None = None) -> dict: """Poll for OAuth completion. - Returns: {status: "pending"} or {status: "connected", connection: {...}} + Returns: {success: true, connection: {...}} or {success: false, pending: true} """ + body: dict = {"deviceCode": device_code} + if code_verifier: + body["codeVerifier"] = code_verifier + if extra_data: + body["extraData"] = extra_data + async with httpx.AsyncClient(timeout=15.0) as client: r = await client.post( f"{NINE_ROUTER_API}/oauth/{provider}/poll", - json={"deviceCode": device_code}, + json=body, ) r.raise_for_status() return r.json() diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 4f391b85..703007ef 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -158,10 +158,10 @@ const CopilotAuthButton: React.FC = () => { // ── Subscription Provider Card ── const SUBSCRIPTION_PROVIDERS = [ - { id: 'claude-code', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A' }, - { id: 'openai-codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C' }, + { id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A' }, + { id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C' }, { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E' }, - { id: 'gemini', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4' }, + { id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4' }, ]; const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => { @@ -213,8 +213,8 @@ const SubscriptionCards: React.FC = () => { const isConnected = (providerId: string) => { if (!status?.providers) return false; - const providers = Array.isArray(status.providers) ? status.providers : []; - return providers.some((p: any) => p.provider === providerId && p.isActive); + const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []); + return connections.some((p: any) => p.provider === providerId && p.isActive); }; const handleConnect = async (providerId: string) => { @@ -226,21 +226,21 @@ const SubscriptionCards: React.FC = () => { body: JSON.stringify({ provider: providerId }), }); const data = await r.json(); - if (data.user_code || data.userCode) { - const code = data.user_code || data.userCode; + + if (data.flow === 'device_code') { + // Device code flow (GitHub, Qwen, etc.) — show code, poll + const code = data.user_code || ''; setUserCode(code); - if (data.verification_uri || data.verificationUri) { - window.open(data.verification_uri || data.verificationUri, '_blank'); - } - // Start polling + if (data.verification_uri) window.open(data.verification_uri, '_blank'); + const timer = setInterval(async () => { try { const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: providerId, device_code: data.device_code || data.deviceCode }), + body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }), }); const pd = await pr.json(); - if (pd.status === 'connected' || pd.success) { + if (pd.success) { clearInterval(timer); setConnecting(null); setUserCode(''); @@ -250,6 +250,30 @@ const SubscriptionCards: React.FC = () => { }, 5000); setPollTimer(timer); setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000); + + } else if (data.flow === 'authorization_code') { + // Auth code flow (Claude, Codex, Gemini) — open browser, poll for connection + if (data.auth_url) window.open(data.auth_url, '_blank'); + + // Poll the providers list until this provider appears as connected + const timer = setInterval(async () => { + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const providers = Array.isArray(sd.providers?.connections) ? sd.providers.connections : Array.isArray(sd.providers) ? sd.providers : []; + const found = providers.some((p: any) => p.provider === providerId && p.isActive); + if (found) { + clearInterval(timer); + setConnecting(null); + fetchStatus(); + } + } catch {} + }, 3000); + setPollTimer(timer); + setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000); + + } else { + setConnecting(null); } } catch { setConnecting(null); } };