diff --git a/GITHUB_COPILOT_INTEGRATION.md b/GITHUB_COPILOT_INTEGRATION.md new file mode 100644 index 00000000..8b4843a8 --- /dev/null +++ b/GITHUB_COPILOT_INTEGRATION.md @@ -0,0 +1,128 @@ +# GitHub Copilot Integration for Rowboat + +Esta implementación agrega soporte completo para **GitHub Copilot** en Rowboat usando **Device Flow OAuth** (RFC 8628). + +## ¿Qué se implementó? + +### 1. **Schema de Proveedores** (`apps/x/packages/shared/src/models.ts`) +- Agregado `"github-copilot"` como flavor de proveedor LLM + +### 2. **Provider LLM** (`apps/x/packages/core/src/models/models.ts`) +- Implementado case para `github-copilot` que usa la API compatible con OpenAI de GitHub Models +- Base URL: `https://models.github.com/api/openai/` + +### 3. **Configuración OAuth** (`apps/x/packages/core/src/auth/providers.ts`) +- Agregado proveedor `github-copilot` con: + - Authorization endpoint: `https://github.com/login/oauth/authorize` + - Token endpoint: `https://github.com/login/oauth/access_token` + - Client ID: `Iv1.b507a08c87ecfe98` (GitHub Copilot CLI Client ID oficial) + - Scopes: `read:user`, `user:email`, `gist` + +### 4. **Device Flow Implementation** (`apps/x/packages/core/src/auth/github-copilot-device-flow.ts`) +Nuevo archivo que implementa RFC 8628: +- `requestDeviceCode()` - Solicita un device code a GitHub +- `pollForToken()` - Sondea GitHub para obtener el token +- `startGitHubCopilotAuth()` - Flujo completo de autenticación + +## Cómo usar + +### 1. **Instalación** + +```bash +cd apps/x +pnpm install +npm run deps +``` + +### 2. **Configuración Manual** (archivo JSON) + +Edita `~/.rowboat/config/models.json`: + +```json +{ + "provider": { + "flavor": "github-copilot", + "apiKey": null + }, + "model": "gpt-4o" +} +``` + +### 3. **Autenticación con Device Flow** + +Cuando Rowboat se inicia con GitHub Copilot configurado: + +1. Se solicita un device code a GitHub +2. Se muestra un código de usuario (ej: `ABCD-1234`) +3. Se abre `https://github.com/login/device` +4. Usuario ingresa el código +5. Rowboat automáticamente sondea y obtiene el token + +``` +┌ GitHub Copilot Authentication +│ +│ Visit: https://github.com/login/device +│ Enter code: ABCD-1234 +│ +│ Waiting for authorization... +└ +``` + +### 4. **Modelos disponibles** + +GitHub Copilot soporta estos modelos: + +- `gpt-4o` - GPT-4 Omni (más capaz, más caro) +- `gpt-4-turbo` - GPT-4 Turbo +- `gpt-3.5-turbo` - GPT-3.5 Turbo (rápido, económico) +- `claude-opus` - Claude Opus (si está disponible) + +## Flujo técnico + +``` +┌─────────────────────────────────────────────────────────┐ +│ Rowboat Application │ +└─────────┬───────────────────────────────────────────────┘ + │ + ├─► requestDeviceCode() + │ └─► POST /login/device/code + │ └─ client_id, scope + │ + ├─► Display: Visit https://github.com/login/device + │ Enter code: ABCD-1234 + │ + ├─► pollForToken() + │ └─► POST /login/oauth/access_token (loop) + │ └─ device_code, client_id, grant_type + │ + └─► createProvider() con apiKey = access_token + └─► POST https://models.github.com/api/openai/v1/chat/completions + └─ Bearer token auth +``` + +## Manejo de errores + +El código maneja varios estados de error de GitHub: + +| Error | Acción | +|-------|--------| +| `authorization_pending` | Continúa sondeando | +| `slow_down` | Aumenta intervalo de sondeo | +| `expired_token` | Falla con mensaje claro | +| `access_denied` | Usuario rechazó | + +## Próximos pasos + +Para completar la integración: + +1. **UI de autenticación** - Integrar en el onboarding step de Rowboat +2. **Almacenamiento de tokens** - Guardar en `~/.rowboat/config/auth.json` +3. **Renovación de tokens** - Implementar refresh token si GitHub lo soporta +4. **Selección de modelos** - Descubrir modelos disponibles automáticamente +5. **Tests** - Agregar tests unitarios para device flow + +## Referencia + +- RFC 8628: Device Authorization Grant - https://tools.ietf.org/html/rfc8628 +- GitHub Device Flow Docs - https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow +- GitHub Models API - https://docs.github.com/en/github/copilot/github-copilot-in-the-cli/about-github-copilot-in-the-cli diff --git a/apps/x/packages/core/src/auth/github-copilot-device-flow.ts b/apps/x/packages/core/src/auth/github-copilot-device-flow.ts new file mode 100644 index 00000000..6af1b904 --- /dev/null +++ b/apps/x/packages/core/src/auth/github-copilot-device-flow.ts @@ -0,0 +1,174 @@ +import { OAuthTokens } from './types.js'; + +/** + * GitHub Copilot Device Flow OAuth + * Implements RFC 8628 - OAuth 2.0 Device Authorization Grant + * + * Reference: https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow + */ + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +export interface DeviceTokenResponse { + access_token?: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; +} + +const GITHUB_DEVICE_CODE_ENDPOINT = 'https://github.com/login/device/code'; +const GITHUB_TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token'; +const DEFAULT_POLLING_INTERVAL = 5000; // 5 seconds + +/** + * Request a device code from GitHub + */ +export async function requestDeviceCode( + clientId: string, + scopes: string[] = ['read:user', 'user:email'] +): Promise { + console.log('[GitHub Copilot] Requesting device code...'); + + const response = await fetch(GITHUB_DEVICE_CODE_ENDPOINT, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + scope: scopes.join(' '), + }).toString(), + }); + + if (!response.ok) { + throw new Error(`Failed to request device code: ${response.statusText}`); + } + + const data = await response.json() as DeviceCodeResponse; + console.log(`[GitHub Copilot] Device code received. User code: ${data.user_code}`); + console.log(`[GitHub Copilot] Verification URI: ${data.verification_uri}`); + + return data; +} + +/** + * Poll GitHub for the access token + * This should be called after the user authenticates + */ +export async function pollForToken( + clientId: string, + deviceCode: string, + expiresAt: number, + onStatusChange?: (status: 'pending' | 'expired' | 'success' | 'error') => void, +): Promise { + console.log('[GitHub Copilot] Polling for token...'); + + const startTime = Date.now(); + const pollingInterval = DEFAULT_POLLING_INTERVAL; + + while (Date.now() < expiresAt) { + try { + const response = await fetch(GITHUB_TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + }); + + if (!response.ok) { + throw new Error(`Token request failed: ${response.statusText}`); + } + + const data = await response.json() as DeviceTokenResponse; + + if (data.error) { + if (data.error === 'authorization_pending') { + console.log('[GitHub Copilot] Authorization pending, polling again...'); + onStatusChange?.('pending'); + await new Promise(resolve => setTimeout(resolve, pollingInterval)); + continue; + } else if (data.error === 'slow_down') { + console.log('[GitHub Copilot] Rate limited, increasing interval...'); + await new Promise(resolve => setTimeout(resolve, pollingInterval * 2)); + continue; + } else if (data.error === 'expired_token') { + console.error('[GitHub Copilot] Device code expired'); + onStatusChange?.('expired'); + throw new Error('Device code expired. Please try again.'); + } else { + console.error(`[GitHub Copilot] Token error: ${data.error}`); + onStatusChange?.('error'); + throw new Error(`Authentication failed: ${data.error_description || data.error}`); + } + } + + if (!data.access_token) { + throw new Error('No access token in response'); + } + + const expiresIn = data.expires_in ?? 3600; + const tokens = OAuthTokens.parse({ + access_token: data.access_token, + refresh_token: data.refresh_token ?? null, + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + token_type: data.token_type ?? 'Bearer', + scopes: data.scope ? data.scope.split(' ') : undefined, + }); + + console.log('[GitHub Copilot] Successfully obtained access token'); + onStatusChange?.('success'); + return tokens; + } catch (error) { + if (error instanceof Error && error.message.includes('Device code expired')) { + throw error; + } + console.error('[GitHub Copilot] Polling error:', error); + await new Promise(resolve => setTimeout(resolve, pollingInterval)); + } + } + + throw new Error('Device code expired before authentication completed'); +} + +/** + * Complete GitHub Copilot authentication flow + * Returns the device code response for display and a promise for the tokens + */ +export async function startGitHubCopilotAuth( + clientId: string, + scopes?: string[] +): Promise<{ + deviceCode: DeviceCodeResponse; + tokenPromise: Promise; +}> { + const deviceCode = await requestDeviceCode(clientId, scopes); + + // Start polling in the background + const tokenPromise = pollForToken( + clientId, + deviceCode.device_code, + Date.now() + deviceCode.expires_in * 1000, + ); + + return { + deviceCode, + tokenPromise, + }; +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 52bd0ab5..65ed038b 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -92,6 +92,23 @@ const providerConfigs: ProviderConfig = { 'profile', 'email', ] + }, + 'github-copilot': { + discovery: { + mode: 'static', + authorizationEndpoint: 'https://github.com/login/oauth/authorize', + tokenEndpoint: 'https://github.com/login/oauth/access_token', + revocationEndpoint: 'https://github.com/settings/connections/applications', + }, + client: { + mode: 'static', + clientId: 'Iv1.b507a08c87ecfe98', + }, + scopes: [ + 'read:user', + 'user:email', + 'gist', + ] } }; diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 38b6801f..87f7c4fc 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -65,6 +65,17 @@ export function createProvider(config: z.infer): ProviderV2 { baseURL, headers, }) as unknown as ProviderV2; + case "github-copilot": + // GitHub Copilot uses OpenAI-compatible API + // baseURL should be set by the auth provider or default to GitHub Copilot endpoint + return createOpenAI({ + apiKey, + baseURL: baseURL || "https://models.github.com/api/openai/", + headers: { + ...headers, + "user-agent": "Rowboat/1.0", + }, + }); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 2c1588e8..d6232bd1 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "github-copilot"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(),