feat: add GitHub Copilot model provider with OAuth device flow
Add a new 'github' model provider backed by the Copilot API (api.githubcopilot.com), with OAuth device flow for authentication. - New src/auth/github.ts: device flow login, token storage at ~/.config/flynn/auth.json with 0600 permissions - New src/models/github.ts: OpenAI-compatible client with streaming, tool calling, and Copilot-specific headers - Add 'github' to provider enum in config schema - Register provider in daemon factory and TUI client factory - Refactor TUI to use provider-agnostic client factory (was hardcoded to AnthropicClient for all tiers) - Add /login command to TUI for interactive OAuth authorization - Add Copilot model cost tracking entries
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz';
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const POLLING_SAFETY_MARGIN_MS = 3000;
|
||||
|
||||
const AUTH_DIR = resolve(homedir(), '.config/flynn');
|
||||
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
github?: {
|
||||
access_token: string;
|
||||
created_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a device code from GitHub to start the OAuth device flow.
|
||||
*/
|
||||
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await fetch(DEVICE_CODE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ client_id: COPILOT_CLIENT_ID }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to request device code: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DeviceCodeResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll GitHub for an access token after the user has entered the device code.
|
||||
* Blocks until the user authorizes or the code expires.
|
||||
*/
|
||||
export async function pollForToken(deviceCode: string, interval: number): Promise<string> {
|
||||
let currentInterval = interval;
|
||||
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS));
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: COPILOT_CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
|
||||
if (data.access_token) {
|
||||
return data.access_token as string;
|
||||
}
|
||||
|
||||
if (data.error === 'authorization_pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.error === 'slow_down') {
|
||||
// Add 5 seconds as per GitHub spec
|
||||
currentInterval = (data.interval as number) ?? currentInterval + 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.error === 'expired_token') {
|
||||
throw new Error('Device code expired. Please try again.');
|
||||
}
|
||||
|
||||
if (data.error === 'access_denied') {
|
||||
throw new Error('Authorization was denied by the user.');
|
||||
}
|
||||
|
||||
throw new Error(`OAuth error: ${data.error ?? 'unknown'} - ${data.error_description ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously stored GitHub OAuth token from disk.
|
||||
* Returns null if no token is stored or the file doesn't exist.
|
||||
*/
|
||||
export function loadStoredToken(): string | null {
|
||||
try {
|
||||
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
||||
const store = JSON.parse(raw) as AuthStore;
|
||||
return store.github?.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a GitHub OAuth token to disk with secure permissions.
|
||||
*/
|
||||
export function storeToken(token: string): void {
|
||||
mkdirSync(AUTH_DIR, { recursive: true });
|
||||
|
||||
let store: AuthStore = {};
|
||||
try {
|
||||
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
||||
store = JSON.parse(raw) as AuthStore;
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh
|
||||
}
|
||||
|
||||
store.github = {
|
||||
access_token: token,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
|
||||
chmodSync(AUTH_FILE, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a GitHub token from any available source.
|
||||
* Priority: GITHUB_TOKEN env var → stored OAuth token → null
|
||||
*/
|
||||
export function getGitHubToken(): string | null {
|
||||
// 1. Environment variable
|
||||
const envToken = process.env.GITHUB_TOKEN;
|
||||
if (envToken) return envToken;
|
||||
|
||||
// 2. Stored OAuth token
|
||||
return loadStoredToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full GitHub OAuth device flow interactively.
|
||||
* @param onPrompt Callback to display the user code and verification URL to the user.
|
||||
* @returns The access token.
|
||||
*/
|
||||
export async function loginGitHub(
|
||||
onPrompt: (userCode: string, verificationUri: string) => void,
|
||||
): Promise<string> {
|
||||
const deviceCode = await requestDeviceCode();
|
||||
|
||||
onPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
||||
|
||||
const token = await pollForToken(deviceCode.device_code, deviceCode.interval);
|
||||
storeToken(token);
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
requestDeviceCode,
|
||||
pollForToken,
|
||||
loadStoredToken,
|
||||
storeToken,
|
||||
getGitHubToken,
|
||||
loginGitHub,
|
||||
type DeviceCodeResponse,
|
||||
} from './github.js';
|
||||
Reference in New Issue
Block a user