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:
William Valentin
2026-02-06 22:26:52 -08:00
parent a515912537
commit f363717f5f
10 changed files with 493 additions and 43 deletions
+164
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
export {
requestDeviceCode,
pollForToken,
loadStoredToken,
storeToken,
getGitHubToken,
loginGitHub,
type DeviceCodeResponse,
} from './github.js';