mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
feat: Add GitHub Copilot support with Device Flow OAuth
- Add github-copilot as LLM provider flavor in schema - Implement GitHub Copilot API integration using OpenAI-compatible endpoint - Add OAuth Device Flow implementation (RFC 8628) for GitHub authentication - Configure GitHub OAuth endpoints and client ID - Add comprehensive documentation for GitHub Copilot integration This allows users to authenticate with GitHub and use GitHub Copilot models (GPT-4o, GPT-4, etc.) directly in Rowboat using device code flow authentication.
This commit is contained in:
parent
2133d7226f
commit
eed4bda7bd
5 changed files with 331 additions and 1 deletions
128
GITHUB_COPILOT_INTEGRATION.md
Normal file
128
GITHUB_COPILOT_INTEGRATION.md
Normal file
|
|
@ -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
|
||||
174
apps/x/packages/core/src/auth/github-copilot-device-flow.ts
Normal file
174
apps/x/packages/core/src/auth/github-copilot-device-flow.ts
Normal file
|
|
@ -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<DeviceCodeResponse> {
|
||||
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<OAuthTokens> {
|
||||
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<OAuthTokens>;
|
||||
}> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,17 @@ export function createProvider(config: z.infer<typeof Provider>): 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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue