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
+5
View File
@@ -14,6 +14,11 @@ export const MODEL_COSTS_PER_MILLION: Record<string, { input: number; output: nu
'gemini-2.5-flash': { input: 0.15, output: 0.60 },
'gemini-1.5-pro': { input: 1.25, output: 5 },
'gemini-1.5-flash': { input: 0.075, output: 0.30 },
// GitHub Copilot (included in subscription, tracked at $0)
'gpt-4.1': { input: 0, output: 0 },
'gpt-4.1-mini': { input: 0, output: 0 },
'claude-sonnet-4': { input: 0, output: 0 },
'claude-haiku-4': { input: 0, output: 0 },
// Local / unknown models
'default': { input: 0, output: 0 },
// Bedrock (Meta Llama)
+236
View File
@@ -0,0 +1,236 @@
import OpenAI from 'openai';
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, MessageContentPart } from './types.js';
import { getGitHubToken } from '../auth/index.js';
export interface GitHubModelsClientConfig {
apiKey?: string; // GitHub PAT or gh auth token. Falls back to GITHUB_TOKEN env var
model: string; // e.g., 'gpt-4o' or 'claude-sonnet-4'
maxTokens?: number;
endpoint?: string; // Override base URL (default: https://models.github.ai/inference)
}
const DEFAULT_ENDPOINT = 'https://api.githubcopilot.com';
/**
* Convert Flynn message content to OpenAI format.
* Reuses the same pattern as openai.ts since GitHub Models uses an OpenAI-compatible API.
*/
function toOpenAIContent(content: string | MessageContentPart[]): string | OpenAI.ChatCompletionContentPart[] {
if (typeof content === 'string') {
return content;
}
return content.map((part): OpenAI.ChatCompletionContentPart => {
if (part.type === 'text') {
return { type: 'text', text: part.text };
}
if (part.type === 'image') {
const url = part.source.type === 'base64'
? `data:${part.source.media_type};base64,${part.source.data!}`
: part.source.url!;
return { type: 'image_url', image_url: { url } };
}
// Fallback — shouldn't happen
return { type: 'text', text: JSON.stringify(part) };
});
}
export class GitHubModelsClient implements ModelClient {
private client: OpenAI;
private model: string;
private defaultMaxTokens: number;
constructor(config: GitHubModelsClientConfig) {
const apiKey = config.apiKey ?? getGitHubToken() ?? '';
const baseURL = config.endpoint ?? DEFAULT_ENDPOINT;
this.client = new OpenAI({
apiKey,
baseURL,
defaultHeaders: {
'X-GitHub-Api-Version': '2022-11-28',
'Openai-Intent': 'conversation-edits',
},
});
this.model = config.model;
this.defaultMaxTokens = config.maxTokens ?? 4096;
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) {
messages.push({ role: 'system', content: request.system });
}
for (const msg of request.messages) {
messages.push({
role: msg.role,
content: toOpenAIContent(msg.content),
} as OpenAI.ChatCompletionMessageParam);
}
const params: OpenAI.ChatCompletionCreateParamsNonStreaming = {
model: this.model,
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
messages,
};
if (request.tools && request.tools.length > 0) {
params.tools = request.tools.map(t => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.input_schema as OpenAI.FunctionParameters,
},
}));
}
const response = await this.client.chat.completions.create(params);
const choice = response.choices[0];
const content = choice?.message?.content ?? '';
const toolCalls = choice?.message?.tool_calls?.map((tc: OpenAI.ChatCompletionMessageToolCall) => ({
id: tc.id,
name: tc.function.name,
args: JSON.parse(tc.function.arguments),
})) ?? [];
// Map OpenAI finish reasons to Flynn's stop reasons
let stopReason: string;
if (toolCalls.length > 0) {
stopReason = 'tool_use';
} else {
const reason = choice?.finish_reason;
if (reason === 'stop') {
stopReason = 'end_turn';
} else if (reason === 'length') {
stopReason = 'max_tokens';
} else {
stopReason = reason ?? 'end_turn';
}
}
return {
content,
stopReason,
usage: {
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
},
...(toolCalls.length > 0 ? { toolCalls } : {}),
};
}
async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) {
messages.push({ role: 'system', content: request.system });
}
for (const msg of request.messages) {
messages.push({
role: msg.role,
content: toOpenAIContent(msg.content),
} as OpenAI.ChatCompletionMessageParam);
}
const params: OpenAI.ChatCompletionCreateParamsStreaming = {
model: this.model,
max_tokens: request.maxTokens ?? this.defaultMaxTokens,
messages,
stream: true,
};
if (request.tools && request.tools.length > 0) {
params.tools = request.tools.map(t => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.input_schema as OpenAI.FunctionParameters,
},
}));
}
try {
const stream = await this.client.chat.completions.create(params);
let totalInputTokens = 0;
let totalOutputTokens = 0;
// Accumulate tool call deltas across chunks
const toolCallAccumulator = new Map<number, {
id: string;
name: string;
arguments: string;
}>();
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
const finishReason = chunk.choices[0]?.finish_reason;
// Emit text content deltas
if (delta?.content) {
yield { type: 'content', content: delta.content };
}
// Accumulate tool call deltas
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCallAccumulator.get(tc.index);
if (existing) {
// Append argument fragments
if (tc.function?.arguments) {
existing.arguments += tc.function.arguments;
}
} else {
toolCallAccumulator.set(tc.index, {
id: tc.id ?? '',
name: tc.function?.name ?? '',
arguments: tc.function?.arguments ?? '',
});
}
}
}
// Track usage from chunks (some providers include it in stream)
if (chunk.usage) {
totalInputTokens = chunk.usage.prompt_tokens ?? totalInputTokens;
totalOutputTokens = chunk.usage.completion_tokens ?? totalOutputTokens;
}
// On finish, emit accumulated tool calls
if (finishReason === 'tool_calls' || finishReason === 'stop') {
for (const [, tc] of toolCallAccumulator) {
yield {
type: 'tool_use',
toolCall: {
id: tc.id,
name: tc.name,
args: JSON.parse(tc.arguments || '{}'),
},
};
}
toolCallAccumulator.clear();
}
}
yield {
type: 'done',
usage: {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
},
};
} catch (error) {
yield {
type: 'error',
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
}
+1
View File
@@ -2,6 +2,7 @@ export { AnthropicClient, type AnthropicClientConfig } from './anthropic.js';
export { OpenAIClient, type OpenAIClientConfig } from './openai.js';
export { GeminiClient, type GeminiClientConfig } from './gemini.js';
export { BedrockClient, type BedrockClientConfig } from './bedrock.js';
export { GitHubModelsClient, type GitHubModelsClientConfig } from './github.js';
export { OllamaClient, type OllamaClientConfig } from './local/index.js';
export { LlamaCppClient, type LlamaCppClientConfig } from './local/index.js';
export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js';