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,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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user