[eric] fix subscription provider IDs, handle both OAuth flows

- 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) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-21 21:03:07 -07:00
co-authored by Claude Opus 4.6
parent 50b28f7080
commit 0a907e4bac
3 changed files with 84 additions and 21 deletions
+5 -1
View File
@@ -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))
+42 -7
View File
@@ -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()
+37 -13
View File
@@ -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); }
};