@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
450 lines (449 loc) • 16.7 kB
JavaScript
/**
* GitHub Copilot Provider for AI Changelog Generator
*
* Uses GitHub Copilot authentication to access AI models.
* Supports users with GitHub Copilot Pro, Pro+, Business, or Enterprise subscriptions.
*
* Authentication sources:
* - ~/.config/github-copilot/hosts.json (Copilot CLI)
* - VS Code GitHub authentication (via extension)
* - Manual token configuration
*
* API Compatibility: OpenAI-compatible endpoint
*/
import fs from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { BaseProvider } from '../core/base-provider.js';
import { applyMixins } from '../utils/base-provider-helpers.js';
/**
* Copilot API endpoints
*/
/**
* Available models through Copilot
*/
const COPILOT_MODELS = {
'gpt-5.6-terra': {
id: 'gpt-5.6-terra',
name: 'GPT-5.6 Terra (via Copilot)',
contextWindow: 1050000,
maxOutput: 128000,
features: ['text', 'vision', 'tools', 'json_mode', 'reasoning'],
},
'gpt-5.6-luna': {
id: 'gpt-5.6-luna',
name: 'GPT-5.6 Luna (via Copilot)',
contextWindow: 1050000,
maxOutput: 128000,
features: ['text', 'vision', 'tools', 'json_mode'],
},
'gpt-5.6-sol': {
id: 'gpt-5.6-sol',
name: 'GPT-5.6 Sol (via Copilot)',
contextWindow: 1050000,
maxOutput: 128000,
features: ['text', 'vision', 'tools', 'json_mode', 'reasoning', 'advanced_reasoning'],
},
'claude-sonnet-5': {
id: 'claude-sonnet-5',
name: 'Claude Sonnet 5 (via Copilot)',
contextWindow: 1000000,
maxOutput: 128000,
features: ['text', 'vision', 'tools', 'adaptive_thinking'],
},
'claude-opus-5': {
id: 'claude-opus-5',
name: 'Claude Opus 5 (via Copilot)',
contextWindow: 1000000,
maxOutput: 128000,
features: ['text', 'vision', 'tools', 'adaptive_thinking', 'advanced_reasoning'],
},
'gemini-3.6-flash': {
id: 'gemini-3.6-flash',
name: 'Gemini 3.6 Flash (via Copilot)',
contextWindow: 1000000,
maxOutput: 65536,
features: ['text', 'vision', 'tools', 'reasoning', 'speed'],
},
};
class GitHubCopilotProvider extends BaseProvider {
constructor(config) {
super(config);
this.homeDir = os.homedir();
this.githubToken = null;
this.copilotToken = null;
this.tokenExpiry = null;
this.machineId = null;
if (this.isAvailable()) {
this.initializeCredentials();
}
}
getName() {
return 'github-copilot';
}
isAvailable() {
// Check for explicit token in config
if (this.config.GITHUB_COPILOT_TOKEN) {
return true;
}
// Check for token from credential detection
if (this.config.GITHUB_TOKEN || this.config.GH_TOKEN) {
return true;
}
// Check for hosts.json file
const hostsPath = path.join(this.homeDir, '.config', 'github-copilot', 'hosts.json');
if (fs.existsSync(hostsPath)) {
try {
const content = fs.readFileSync(hostsPath, 'utf8');
const hosts = JSON.parse(content);
return !!hosts['github.com']?.oauth_token;
}
catch {
return false;
}
}
// Check gh CLI hosts.yml
const ghHostsPath = path.join(this.homeDir, '.config', 'gh', 'hosts.yml');
if (fs.existsSync(ghHostsPath)) {
try {
const content = fs.readFileSync(ghHostsPath, 'utf8');
return content.includes('oauth_token:');
}
catch {
return false;
}
}
return false;
}
initializeCredentials() {
// Priority 1: Explicit token in config
if (this.config.GITHUB_COPILOT_TOKEN) {
this.githubToken = this.config.GITHUB_COPILOT_TOKEN;
return;
}
// Priority 2: GitHub token from env/config
if (this.config.GITHUB_TOKEN || this.config.GH_TOKEN) {
this.githubToken = this.config.GITHUB_TOKEN || this.config.GH_TOKEN;
return;
}
// Priority 3: hosts.json (Copilot CLI)
const hostsPath = path.join(this.homeDir, '.config', 'github-copilot', 'hosts.json');
if (fs.existsSync(hostsPath)) {
try {
const content = fs.readFileSync(hostsPath, 'utf8');
const hosts = JSON.parse(content);
if (hosts['github.com']?.oauth_token) {
this.githubToken = hosts['github.com'].oauth_token;
return;
}
}
catch {
// Continue to next source
}
}
// Priority 4: gh CLI hosts.yml
const ghHostsPath = path.join(this.homeDir, '.config', 'gh', 'hosts.yml');
if (fs.existsSync(ghHostsPath)) {
try {
const content = fs.readFileSync(ghHostsPath, 'utf8');
const tokenMatch = content.match(/oauth_token:\s*(.+)/);
if (tokenMatch && tokenMatch[1]) {
this.githubToken = tokenMatch[1].trim();
}
}
catch {
// No token found
}
}
}
/**
* Get a Copilot API token using the GitHub OAuth token
* @returns {Promise<string>}
*/
async getCopilotToken() {
// Return cached token if still valid
if (this.copilotToken && this.tokenExpiry && new Date() < this.tokenExpiry) {
return this.copilotToken;
}
if (!this.githubToken) {
throw new Error('No GitHub token available for Copilot authentication');
}
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.github.com',
port: 443,
path: '/copilot_internal/v2/token',
method: 'GET',
headers: {
Authorization: `token ${this.githubToken}`,
'User-Agent': 'ai-changelog-generator/1.0',
Accept: 'application/json',
'Editor-Version': 'vscode/1.95.0',
'Editor-Plugin-Version': 'copilot/1.250.0',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`Failed to get Copilot token: ${res.statusCode} - ${data}`));
return;
}
try {
const tokenData = JSON.parse(data);
this.copilotToken = tokenData.token;
// Token typically expires in 30 minutes
this.tokenExpiry = new Date(Date.now() +
(tokenData.expires_at ? tokenData.expires_at * 1000 - Date.now() : 25 * 60 * 1000));
resolve(this.copilotToken);
}
catch (error) {
reject(new Error(`Failed to parse Copilot token response: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`Copilot token request failed: ${error.message}`));
});
req.end();
});
}
async generateCompletion(messages, options = {}) {
if (!this.isAvailable()) {
return this.handleProviderError(new Error('GitHub Copilot provider is not configured'), 'generate_completion');
}
try {
const copilotToken = await this.getCopilotToken();
const modelConfig = this.getProviderModelConfig();
const modelName = options.model || modelConfig.standardModel;
// Convert messages to OpenAI format (Copilot uses OpenAI-compatible API)
const formattedMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
const requestBody = {
model: modelName,
messages: formattedMessages,
max_tokens: options.max_tokens || 4096,
temperature: options.temperature || 0.3,
top_p: options.top_p || 1,
stream: false,
};
if (options.response_format?.type === 'json_object') {
requestBody.response_format = { type: 'json_object' };
}
if (options.tools && options.tools.length > 0) {
requestBody.tools = options.tools;
requestBody.tool_choice = options.tool_choice || 'auto';
}
const response = await this.makeRequest(copilotToken, requestBody);
const firstChoice = response.choices?.[0];
return {
content: firstChoice?.message?.content || '',
model: modelName,
tool_calls: firstChoice?.message?.tool_calls,
tokens: response.usage?.total_tokens || 0,
finish_reason: firstChoice?.finish_reason || 'stop',
};
}
catch (error) {
// Handle token expiry
if (error.message.includes('401') || error.message.includes('403')) {
this.copilotToken = null;
this.tokenExpiry = null;
// Retry once with fresh token
if (!options._retried) {
return this.generateCompletion(messages, { ...options, _retried: true });
}
}
return this.handleProviderError(error, 'generate_completion', { model: options.model });
}
}
/**
* Make a request to the Copilot API
* @param {string} token - Copilot API token
* @param {Object} body - Request body
* @returns {Promise<Object>}
*/
makeRequest(token, body) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify(body);
const options = {
hostname: 'api.githubcopilot.com',
port: 443,
path: '/chat/completions',
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'ai-changelog-generator/1.0',
Accept: 'application/json',
'Editor-Version': 'vscode/1.95.0',
'Editor-Plugin-Version': 'copilot/1.250.0',
'Copilot-Integration-Id': 'vscode-chat',
'Openai-Intent': 'conversation-panel',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`Copilot API error: ${res.statusCode} - ${data}`));
return;
}
try {
resolve(JSON.parse(data));
}
catch (error) {
reject(new Error(`Failed to parse Copilot response: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`Copilot API request failed: ${error.message}`));
});
req.write(postData);
req.end();
});
}
getModelRecommendation(commitDetails) {
const { files = 0, lines = 0, breaking = false, complex = false } = commitDetails;
if (breaking || complex || files > 20 || lines > 500) {
return {
model: 'gpt-5.6-terra',
reason: 'Complex changes requiring advanced reasoning',
};
}
if (files > 5 || lines > 100) {
return {
model: 'gpt-5.6-terra',
reason: 'Standard analysis with good capability',
};
}
return {
model: 'gpt-5.6-luna',
reason: 'Simple changes, using faster model',
};
}
async validateModelAvailability(modelName) {
const model = COPILOT_MODELS[modelName];
return {
available: !!model,
model: model || null,
reason: model ? 'Model available via Copilot' : 'Model not available',
};
}
async testConnection() {
if (!this.isAvailable()) {
return {
success: false,
error: 'GitHub Copilot credentials not found',
};
}
try {
const startTime = Date.now();
await this.getCopilotToken();
const tokenTime = Date.now() - startTime;
// Test actual completion
const testStart = Date.now();
await this.generateCompletion([{ role: 'user', content: 'Say "test" in one word.' }], {
max_tokens: 5,
});
const completionTime = Date.now() - testStart;
return {
success: true,
responseTime: completionTime,
tokenFetchTime: tokenTime,
message: 'GitHub Copilot connection successful',
};
}
catch (error) {
return {
success: false,
error: error.message,
};
}
}
async getAvailableModels() {
return Object.values(COPILOT_MODELS);
}
getRequiredEnvVars() {
return ['GITHUB_COPILOT_TOKEN']; // Or detected from hosts.json
}
/**
* Check if user has active Copilot subscription
* @returns {Promise<Object>}
*/
async checkSubscription() {
if (!this.githubToken) {
return { active: false, error: 'No GitHub token' };
}
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled)
return;
settled = true;
resolve(result);
};
const options = {
hostname: 'api.github.com',
port: 443,
path: '/copilot_internal/user',
method: 'GET',
headers: {
Authorization: `token ${this.githubToken}`,
'User-Agent': 'ai-changelog-generator/1.0',
Accept: 'application/json',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
try {
const userData = JSON.parse(data);
finish({
active: true,
plan: userData.copilot_plan || 'unknown',
organization: userData.organization_login,
});
}
catch {
finish({ active: true, plan: 'unknown' });
}
}
else {
finish({ active: false, statusCode: res.statusCode });
}
});
});
req.on('error', () => {
finish({ active: false, error: 'Request failed' });
});
req.end();
});
}
/**
* Set GitHub token manually (for VS Code extension integration)
* @param {string} token
*/
setGitHubToken(token) {
this.githubToken = token;
this.copilotToken = null;
this.tokenExpiry = null;
}
}
export default applyMixins(GitHubCopilotProvider, 'github-copilot');
export { GitHubCopilotProvider };