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
+27 -41
View File
@@ -43,7 +43,7 @@ export function registerTuiCommand(program: Command): void {
// Dynamic imports to keep CLI startup fast
const { SessionStore, SessionManager } = await import('../session/index.js');
const { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter } = await import('../models/index.js');
const { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GitHubModelsClient, GeminiClient, BedrockClient, ModelRouter } = await import('../models/index.js');
const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js');
const { NativeAgent } = await import('../backends/index.js');
const { ToolRegistry, ToolExecutor, allBuiltinTools } = await import('../tools/index.js');
@@ -56,49 +56,35 @@ export function registerTuiCommand(program: Command): void {
const sessionManager = new SessionManager(sessionStore);
const models = config.models;
// Build model router
const defaultClient = new AnthropicClient({
model: models.default.model,
apiKey: models.default.api_key,
authToken: models.default.auth_token,
});
let fastClient;
let complexClient;
let localClient;
if (models.fast) {
fastClient = new AnthropicClient({
model: models.fast.model,
apiKey: models.fast.api_key,
authToken: models.fast.auth_token,
});
}
if (models.complex) {
complexClient = new AnthropicClient({
model: models.complex.model,
apiKey: models.complex.api_key,
authToken: models.complex.auth_token,
});
}
if (models.local) {
if (models.local.provider === 'ollama') {
localClient = new OllamaClient({
model: models.local.model,
host: models.local.endpoint,
numGpu: models.local.num_gpu,
});
} else if (models.local.provider === 'llamacpp') {
localClient = new LlamaCppClient({
endpoint: models.local.endpoint ?? 'http://localhost:8080',
model: models.local.model,
authToken: models.local.auth_token,
});
// Provider-agnostic client factory for TUI
function createClient(cfg: typeof models.default) {
switch (cfg.provider) {
case 'anthropic':
return new AnthropicClient({ model: cfg.model, apiKey: cfg.api_key, authToken: cfg.auth_token });
case 'openai':
return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key });
case 'gemini':
return new GeminiClient({ model: cfg.model, apiKey: cfg.api_key });
case 'ollama':
return new OllamaClient({ model: cfg.model, host: cfg.endpoint, numGpu: cfg.num_gpu });
case 'llamacpp':
return new LlamaCppClient({ endpoint: cfg.endpoint ?? 'http://localhost:8080', model: cfg.model, authToken: cfg.auth_token });
case 'openrouter':
return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key ?? process.env.OPENROUTER_API_KEY, baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1' });
case 'bedrock':
return new BedrockClient({ model: cfg.model, region: cfg.endpoint, accessKeyId: cfg.api_key, secretAccessKey: cfg.auth_token });
case 'github':
return new GitHubModelsClient({ model: cfg.model, apiKey: cfg.api_key, endpoint: cfg.endpoint });
default:
throw new Error(`Unknown provider: ${cfg.provider}`);
}
}
const defaultClient = createClient(models.default);
const fastClient = models.fast ? createClient(models.fast) : undefined;
const complexClient = models.complex ? createClient(models.complex) : undefined;
const localClient = models.local ? createClient(models.local) : undefined;
const fallbackChain = [];
for (const providerName of models.fallback_chain) {
if (providerName === 'openai') {