This commit is contained in:
Wilber Ticllasuca 2026-04-23 09:03:38 +07:00 committed by GitHub
commit 153519f01d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 5144 additions and 18 deletions

View file

@ -872,7 +872,7 @@ export async function* streamAgent({
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(modelConfig.provider);
: await createProvider(modelConfig.provider);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
const isInlineTaskAgent = state.agentName === "inline_task_agent";

View file

@ -751,7 +751,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const modelConfig = await modelConfigRepo.getConfig();
const provider = await isSignedIn()
? await getGatewayProvider()
: createProvider(modelConfig.provider);
: await createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model);
const userPrompt = prompt || 'Convert this file to well-structured markdown.';

View file

@ -0,0 +1,224 @@
/**
* GitHub Copilot Authentication Service
*
* Handles Device Flow OAuth authentication with GitHub and token management
* for GitHub Copilot LLM access.
*/
import container from '../di/container.js';
import { IOAuthRepo } from './repo.js';
import { getProviderConfig } from './providers.js';
import * as deviceFlow from './github-copilot-device-flow.js';
import * as oauthClient from './oauth-client.js';
const PROVIDER_NAME = 'github-copilot';
/**
* Start GitHub Copilot authentication flow
*
* Returns device code info for display and a promise for the tokens
* The promise will resolve once the user authenticates on GitHub
*/
export async function startGitHubCopilotAuthentication(): Promise<{
userCode: string;
verificationUri: string;
expiresIn: number;
tokenPromise: Promise<void>;
}> {
console.log('[GitHub Copilot] Starting Device Flow authentication...');
const config = await getProviderConfig(PROVIDER_NAME);
if (config.discovery.mode !== 'static') {
throw new Error('GitHub Copilot provider requires static discovery mode');
}
if (config.client.mode !== 'static') {
throw new Error('GitHub Copilot provider requires static client mode');
}
const clientId = config.client.clientId;
if (!clientId) {
throw new Error('GitHub Copilot provider requires a client ID');
}
// Start Device Flow
const { deviceCode, tokenPromise } = await deviceFlow.startGitHubCopilotAuth(
clientId,
config.scopes
);
// Handle token polling in the background
const authPromise = tokenPromise
.then(async (tokens) => {
console.log('[GitHub Copilot] Authentication successful, saving tokens...');
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
await oauthRepo.upsert(PROVIDER_NAME, {
tokens,
clientId,
});
console.log('[GitHub Copilot] Tokens saved successfully');
})
.catch((error) => {
console.error('[GitHub Copilot] Authentication failed:', error);
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
// Save error state
oauthRepo.upsert(PROVIDER_NAME, {
error: error instanceof Error ? error.message : 'Unknown error',
}).catch(console.error);
throw error;
});
return {
userCode: deviceCode.user_code,
verificationUri: deviceCode.verification_uri,
expiresIn: deviceCode.expires_in,
tokenPromise: authPromise,
};
}
/**
* Get GitHub Copilot access token
*
* Retrieves the saved token and refreshes it if expired.
* Note: GitHub Device Flow may not support refresh tokens, so expired tokens
* will require re-authentication via Device Flow.
*/
export async function getGitHubCopilotAccessToken(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const connection = await oauthRepo.read(PROVIDER_NAME);
if (!connection.tokens) {
throw new Error(
'GitHub Copilot not authenticated. Please authenticate first using Device Flow.'
);
}
// Check if token is expired
if (!oauthClient.isTokenExpired(connection.tokens)) {
return connection.tokens.access_token;
}
// Token is expired
// GitHub Device Flow may not support refresh tokens
// If we have a refresh token, try to use it; otherwise, we need re-authentication
if (connection.tokens.refresh_token) {
console.log('[GitHub Copilot] Token expired, attempting refresh...');
try {
const config = await getProviderConfig(PROVIDER_NAME);
if (config.discovery.mode !== 'static') {
throw new Error('Invalid provider config');
}
// For Device Flow, refresh tokens might not be supported
// This is a fallback in case GitHub adds support
const clientId = config.client.mode === 'static' ? config.client.clientId : null;
if (!clientId) {
throw new Error('Cannot refresh without client ID');
}
// Create static config for refresh
const staticConfig = oauthClient.createStaticConfiguration(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
clientId
);
const refreshed = await oauthClient.refreshTokens(
staticConfig,
connection.tokens.refresh_token,
connection.tokens.scopes
);
await oauthRepo.upsert(PROVIDER_NAME, { tokens: refreshed });
console.log('[GitHub Copilot] Token refreshed successfully');
return refreshed.access_token;
} catch (error) {
console.error('[GitHub Copilot] Token refresh failed:', error);
// Fall through to re-authentication error
}
}
// Token is expired and we cannot refresh
throw new Error(
'GitHub Copilot token expired. Please authenticate again using Device Flow.'
);
}
/**
* Check if GitHub Copilot is authenticated
*/
export async function isGitHubCopilotAuthenticated(): Promise<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const connection = await oauthRepo.read(PROVIDER_NAME);
return !!connection.tokens;
}
/**
* Get GitHub Copilot authentication status
*/
export async function getGitHubCopilotAuthStatus(): Promise<{
authenticated: boolean;
expiresAt?: number;
error?: string;
}> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const connection = await oauthRepo.read(PROVIDER_NAME);
if (!connection.tokens) {
return {
authenticated: false,
error: connection.error ?? undefined,
};
}
return {
authenticated: true,
expiresAt: connection.tokens.expires_at,
};
}
/**
* Disconnect GitHub Copilot (remove stored tokens)
*/
/**
* Get GitHub Copilot API Token (exchange OAuth token for Copilot JWT)
*/
let cachedCopilotToken: { token: string; expiresAt: number } | null = null;
export async function getGitHubCopilotApiToken(): Promise<string> {
// Return cached token if valid (with 5 min buffer)
if (cachedCopilotToken && cachedCopilotToken.expiresAt > Date.now() / 1000 + 300) {
return cachedCopilotToken.token;
}
const oauthToken = await getGitHubCopilotAccessToken();
const response = await fetch('https://api.github.com/copilot_internal/v2/token', {
headers: {
'Authorization': `Bearer ${oauthToken}`,
'Accept': 'application/json',
'User-Agent': 'GithubCopilot/1.155.0'
}
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to get Copilot token: ${response.status} ${err}`);
}
const data = await response.json() as { token: string; expires_at: number };
cachedCopilotToken = {
token: data.token,
expiresAt: data.expires_at
};
return data.token;
}
export async function disconnectGitHubCopilot(): Promise<void> {
console.log('[GitHub Copilot] Disconnecting...');
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
await oauthRepo.delete(PROVIDER_NAME);
cachedCopilotToken = null;
console.log('[GitHub Copilot] Disconnected successfully');
}

View file

@ -0,0 +1,173 @@
import { OAuthTokens } from './types.js';
/**
* GitHub Copilot Device Flow OAuth
* Implements RFC 8628 - OAuth 2.0 Device Authorization Grant
*
* Reference: https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow
*/
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
}
export interface DeviceTokenResponse {
access_token?: string;
expires_in?: number;
refresh_token?: string;
refresh_token_expires_in?: number;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
}
const GITHUB_DEVICE_CODE_ENDPOINT = 'https://github.com/login/device/code';
const GITHUB_TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token';
const DEFAULT_POLLING_INTERVAL = 5000; // 5 seconds
/**
* Request a device code from GitHub
*/
export async function requestDeviceCode(
clientId: string,
scopes: string[] = ['read:user', 'user:email']
): Promise<DeviceCodeResponse> {
console.log('[GitHub Copilot] Requesting device code...');
const response = await fetch(GITHUB_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
scope: scopes.join(' '),
}).toString(),
});
if (!response.ok) {
throw new Error(`Failed to request device code: ${response.statusText}`);
}
const data = await response.json() as DeviceCodeResponse;
console.log(`[GitHub Copilot] Device code received. User code: ${data.user_code}`);
console.log(`[GitHub Copilot] Verification URI: ${data.verification_uri}`);
return data;
}
/**
* Poll GitHub for the access token
* This should be called after the user authenticates
*/
export async function pollForToken(
clientId: string,
deviceCode: string,
expiresAt: number,
onStatusChange?: (status: 'pending' | 'expired' | 'success' | 'error') => void,
): Promise<OAuthTokens> {
console.log('[GitHub Copilot] Polling for token...');
const pollingInterval = DEFAULT_POLLING_INTERVAL;
while (Date.now() < expiresAt) {
try {
const response = await fetch(GITHUB_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.statusText}`);
}
const data = await response.json() as DeviceTokenResponse;
if (data.error) {
if (data.error === 'authorization_pending') {
console.log('[GitHub Copilot] Authorization pending, polling again...');
onStatusChange?.('pending');
await new Promise(resolve => setTimeout(resolve, pollingInterval));
continue;
} else if (data.error === 'slow_down') {
console.log('[GitHub Copilot] Rate limited, increasing interval...');
await new Promise(resolve => setTimeout(resolve, pollingInterval * 2));
continue;
} else if (data.error === 'expired_token') {
console.error('[GitHub Copilot] Device code expired');
onStatusChange?.('expired');
throw new Error('Device code expired. Please try again.');
} else {
console.error(`[GitHub Copilot] Token error: ${data.error}`);
onStatusChange?.('error');
throw new Error(`Authentication failed: ${data.error_description || data.error}`);
}
}
if (!data.access_token) {
throw new Error('No access token in response');
}
const expiresIn = data.expires_in ?? 3600;
const tokens = OAuthTokens.parse({
access_token: data.access_token,
refresh_token: data.refresh_token ?? null,
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
token_type: data.token_type ?? 'Bearer',
scopes: data.scope ? data.scope.split(' ') : undefined,
});
console.log('[GitHub Copilot] Successfully obtained access token');
onStatusChange?.('success');
return tokens;
} catch (error) {
if (error instanceof Error && error.message.includes('Device code expired')) {
throw error;
}
console.error('[GitHub Copilot] Polling error:', error);
await new Promise(resolve => setTimeout(resolve, pollingInterval));
}
}
throw new Error('Device code expired before authentication completed');
}
/**
* Complete GitHub Copilot authentication flow
* Returns the device code response for display and a promise for the tokens
*/
export async function startGitHubCopilotAuth(
clientId: string,
scopes?: string[]
): Promise<{
deviceCode: DeviceCodeResponse;
tokenPromise: Promise<OAuthTokens>;
}> {
const deviceCode = await requestDeviceCode(clientId, scopes);
// Start polling in the background
const tokenPromise = pollForToken(
clientId,
deviceCode.device_code,
Date.now() + deviceCode.expires_in * 1000,
);
return {
deviceCode,
tokenPromise,
};
}

View file

@ -0,0 +1,112 @@
/**
* GitHub Copilot Model Integration
*
* Handles GitHub Copilot model discovery and LLM provider initialization
*/
import { getGitHubCopilotApiToken, isGitHubCopilotAuthenticated } from './github-copilot-auth.js';
import { ProviderV2 } from '@ai-sdk/provider';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import z from 'zod';
import { LlmProvider } from '@x/shared/dist/models.js';
// GitHub Copilot API endpoint
const GITHUB_COPILOT_API_BASE = 'https://api.githubcopilot.com/';
// List of models available through GitHub Copilot
// Based on GitHub Copilot API documentation
// https://docs.github.com/en/copilot/using-github-copilot/asking-github-copilot-questions
export const GITHUB_COPILOT_MODELS = [
'gpt-5.4-mini',
'gpt-5-mini',
'grok-code-fast-1',
'claude-haiku-4.5',
'gemini-3-flash-preview',
'gpt-5.2',
'gpt-4.1',
'gpt-4o',
'gemini-3.1-pro-preview',
'gpt-5.2-codex',
'gpt-5.3-codex',
'gemini-2.5-pro'
] as const;
export type GitHubCopilotModel = typeof GITHUB_COPILOT_MODELS[number];
/**
* Get available GitHub Copilot models
*
* Returns a list of models that are available through GitHub Copilot
*/
export async function getAvailableGitHubCopilotModels(): Promise<string[]> {
// For now, return all models
// In the future, we could query the GitHub Models API to get the actual
// list of available models for the authenticated user
return [...GITHUB_COPILOT_MODELS];
}
/**
* Create GitHub Copilot LLM provider
*
* This automatically handles authentication and uses Device Flow if needed
*/
export async function createGitHubCopilotProvider(
config: z.infer<typeof LlmProvider>
): Promise<ProviderV2> {
if (config.flavor !== 'github-copilot') {
throw new Error('Invalid provider config for GitHub Copilot');
}
// Check if authenticated
const authenticated = await isGitHubCopilotAuthenticated();
if (!authenticated) {
throw new Error(
'GitHub Copilot not authenticated. Please authenticate via Device Flow first.'
);
}
// Get Copilot API token (handles refresh if needed)
const accessToken = await getGitHubCopilotApiToken();
// Create OpenAI-compatible provider with GitHub Copilot endpoint
return createOpenAICompatible({
name: "github-copilot",
apiKey: accessToken,
baseURL: config.baseURL || GITHUB_COPILOT_API_BASE,
headers: {
...config.headers,
'Editor-Version': 'vscode/1.88.0',
'Editor-Plugin-Version': 'copilot-chat/0.14.0',
'User-Agent': 'GitHubCopilotChat/0.14.0',
'Accept': '*/*',
},
});
}
/**
* Test GitHub Copilot connection
*
* Verifies that authentication works and we can reach the API
*/
export async function testGitHubCopilotConnection(): Promise<{ success: boolean; error?: string }> {
try {
// Check if authenticated
const authenticated = await isGitHubCopilotAuthenticated();
if (!authenticated) {
return {
success: false,
error: 'GitHub Copilot not authenticated',
};
}
// Try to get access token
await getGitHubCopilotApiToken();
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -0,0 +1,353 @@
/**
* GitHub Copilot Tests
*
* Comprehensive test suite for Device Flow OAuth and GitHub Copilot integration
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as deviceFlow from '../src/auth/github-copilot-device-flow';
import { OAuthTokens } from '../src/auth/types';
// Mock fetch
const originalFetch = global.fetch;
const mockFetch = vi.fn();
beforeEach(() => {
global.fetch = mockFetch as any; // eslint-disable-line @typescript-eslint/no-explicit-any
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
describe('GitHub Copilot Device Flow', () => {
describe('requestDeviceCode', () => {
it('should request device code successfully', async () => {
const mockResponse = {
device_code: 'test_device_code',
user_code: 'ABCD-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await deviceFlow.requestDeviceCode('test_client_id');
expect(result).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(
'https://github.com/login/device/code',
expect.objectContaining({
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
});
it('should handle request errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request',
});
await expect(
deviceFlow.requestDeviceCode('test_client_id')
).rejects.toThrow('Failed to request device code');
});
it('should support custom scopes', async () => {
const mockResponse = {
device_code: 'test_device_code',
user_code: 'ABCD-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
await deviceFlow.requestDeviceCode('test_client_id', ['read:user', 'gist']);
const call = mockFetch.mock.calls[0];
expect(call[1].body).toContain('scope=read:user+gist');
});
});
describe('pollForToken', () => {
it('should successfully poll and get token', async () => {
const tokenResponse = {
access_token: 'test_access_token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'read:user user:email gist',
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => tokenResponse,
});
const tokens = await deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
Date.now() + 10000
);
expect(tokens.access_token).toBe('test_access_token');
expect(tokens.token_type).toBe('Bearer');
expect(tokens.expires_at).toBeGreaterThan(0);
expect(tokens.scopes).toContain('read:user');
});
it('should handle authorization_pending error', async () => {
const pendingResponse = {
error: 'authorization_pending',
};
const successResponse = {
access_token: 'test_access_token',
token_type: 'Bearer',
expires_in: 3600,
};
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => pendingResponse,
})
.mockResolvedValueOnce({
ok: true,
json: async () => successResponse,
});
const tokens = await deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
Date.now() + 10000
);
expect(tokens.access_token).toBe('test_access_token');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should handle slow_down error', async () => {
const slowDownResponse = {
error: 'slow_down',
};
const successResponse = {
access_token: 'test_access_token',
token_type: 'Bearer',
expires_in: 3600,
};
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => slowDownResponse,
})
.mockResolvedValueOnce({
ok: true,
json: async () => successResponse,
});
const startTime = Date.now();
const tokens = await deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
startTime + 20000
);
expect(tokens.access_token).toBe('test_access_token');
});
it('should handle expired_token error', async () => {
const expiredResponse = {
error: 'expired_token',
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => expiredResponse,
});
await expect(
deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
Date.now() + 1000
)
).rejects.toThrow('Device code expired');
});
it('should handle access_denied error', async () => {
const deniedResponse = {
error: 'access_denied',
error_description: 'User cancelled',
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => deniedResponse,
});
await expect(
deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
Date.now() + 10000
)
).rejects.toThrow('User cancelled');
});
it('should handle timeout', async () => {
const pendingResponse = {
error: 'authorization_pending',
};
mockFetch.mockResolvedValue({
ok: true,
json: async () => pendingResponse,
});
// Set expiration in the past
const expiredTime = Date.now() - 1000;
await expect(
deviceFlow.pollForToken(
'test_client_id',
'test_device_code',
expiredTime
)
).rejects.toThrow('Device code expired');
});
});
describe('startGitHubCopilotAuth', () => {
it('should start authentication flow', async () => {
const deviceCodeResponse = {
device_code: 'test_device_code',
user_code: 'ABCD-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
};
const tokenResponse = {
access_token: 'test_access_token',
token_type: 'Bearer',
expires_in: 3600,
};
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => deviceCodeResponse,
})
.mockResolvedValueOnce({
ok: true,
json: async () => tokenResponse,
});
const { deviceCode, tokenPromise } = await deviceFlow.startGitHubCopilotAuth('test_client_id');
expect(deviceCode.user_code).toBe('ABCD-1234');
expect(deviceCode.verification_uri).toBe('https://github.com/login/device');
const tokens = await tokenPromise;
expect(tokens.access_token).toBe('test_access_token');
});
it('should support custom scopes in auth flow', async () => {
const deviceCodeResponse = {
device_code: 'test_device_code',
user_code: 'ABCD-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => deviceCodeResponse,
});
const customScopes = ['read:user', 'gist', 'repo'];
await deviceFlow.startGitHubCopilotAuth('test_client_id', customScopes);
const call = mockFetch.mock.calls[0];
expect(call[1].body).toContain('read:user');
expect(call[1].body).toContain('gist');
expect(call[1].body).toContain('repo');
});
});
describe('OAuthTokens validation', () => {
it('should properly parse tokens', () => {
const tokenData = {
access_token: 'test_token',
refresh_token: null,
expires_at: 1234567890,
token_type: 'Bearer' as const,
scopes: ['read:user', 'user:email'],
};
const tokens = OAuthTokens.parse(tokenData);
expect(tokens.access_token).toBe('test_token');
expect(tokens.refresh_token).toBeNull();
expect(tokens.expires_at).toBe(1234567890);
expect(tokens.token_type).toBe('Bearer');
expect(tokens.scopes).toEqual(['read:user', 'user:email']);
});
it('should validate token structure', () => {
const invalidTokenData = {
access_token: '', // Empty token
refresh_token: null,
expires_at: 0, // Invalid expiration
};
expect(() => {
OAuthTokens.parse(invalidTokenData);
}).toThrow();
});
});
});
describe('GitHub Copilot Models', () => {
describe('Model availability', () => {
it('should list available models', async () => {
const { getAvailableGitHubCopilotModels } = await import('../src/auth/github-copilot-models');
const models = await getAvailableGitHubCopilotModels();
expect(models).toContain('gpt-4o');
expect(models).toContain('gpt-4-turbo');
expect(models).toContain('gpt-4');
expect(models).toContain('gpt-3.5-turbo');
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
});
});
describe('Model constants', () => {
it('should have valid model names', async () => {
const { GITHUB_COPILOT_MODELS } = await import('../src/auth/github-copilot-models');
expect(GITHUB_COPILOT_MODELS).toContain('gpt-4o');
expect(GITHUB_COPILOT_MODELS).toContain('gpt-4-turbo');
expect(GITHUB_COPILOT_MODELS.length).toBeGreaterThan(0);
});
});
});

View file

@ -92,6 +92,23 @@ const providerConfigs: ProviderConfig = {
'profile',
'email',
]
},
'github-copilot': {
discovery: {
mode: 'static',
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
revocationEndpoint: 'https://github.com/settings/connections/applications',
},
client: {
mode: 'static',
clientId: 'Iv1.b507a08c87ecfe98',
},
scopes: [
'read:user',
'user:email',
'gist',
]
}
};

View file

@ -7,7 +7,7 @@ export const OAuthTokens = z.object({
access_token: z.string(),
refresh_token: z.string().nullable(),
expires_at: z.number(), // Unix timestamp
token_type: z.literal('Bearer').optional(),
token_type: z.string().optional(), // Accept any token type (Bearer, bearer, etc.)
scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response
});

View file

@ -613,7 +613,7 @@ export async function processRowboatInstruction(
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const provider = createProvider(config.provider);
const provider = await createProvider(config.provider);
const model = provider.languageModel(config.model);
const now = new Date();

View file

@ -143,7 +143,7 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
: await createProvider(config.provider);
const modelId = config.meetingNotesModel
|| (signedIn ? "gpt-5.4" : config.model);
const model = provider.languageModel(modelId);

View file

@ -2,6 +2,7 @@ import { ProviderV2 } from '@ai-sdk/provider';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
import { isGitHubCopilotAuthenticated } from '../auth/github-copilot-auth.js';
const authedFetch: typeof fetch = async (input, init) => {
const token = await getAccessToken();
@ -38,11 +39,32 @@ export async function listGatewayModels(): Promise<{ providers: ProviderSummary[
}
const body = await response.json() as { data: Array<{ id: string }> };
const models = body.data.map((m) => ({ id: m.id }));
return {
providers: [{
id: 'rowboat',
name: 'Rowboat',
models,
}],
};
const providers: ProviderSummary[] = [{
id: 'rowboat',
name: 'Rowboat',
models,
}];
// Add GitHub Copilot models always so they appear in UI
providers.push({
id: "github-copilot",
name: "GitHub Copilot Student",
models: [
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini" },
{ id: "gpt-5-mini", name: "GPT-5 mini" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash (Preview)" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro (Preview)" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gemini-2.5-pro-preview", name: "Gemini 2.5 Pro (Preview)" }
],
});
return { providers };
}

View file

@ -222,5 +222,25 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma
});
}
// Add GitHub Copilot models
providers.push({
id: "github-copilot",
name: "GitHub Copilot Student",
models: [
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini" },
{ id: "gpt-5-mini", name: "GPT-5 mini" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash (Preview)" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro (Preview)" },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex" },
{ id: "gemini-2.5-pro-preview", name: "Gemini 2.5 Pro (Preview)" }
],
});
return { providers, lastUpdated: fetchedAt };
}

View file

@ -10,11 +10,12 @@ import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { isSignedIn } from "../account/account.js";
import { getGatewayProvider } from "./gateway.js";
import { createGitHubCopilotProvider } from "../auth/github-copilot-models.js";
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
export async function createProvider(config: z.infer<typeof Provider>): Promise<ProviderV2> {
const { apiKey, baseURL, headers } = config;
switch (config.flavor) {
case "openai":
@ -65,6 +66,9 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
baseURL,
headers,
}) as unknown as ProviderV2;
case "github-copilot":
// GitHub Copilot uses Device Flow OAuth for authentication
return await createGitHubCopilotProvider(config);
default:
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
}
@ -82,7 +86,7 @@ export async function testModelConnection(
try {
const provider = await isSignedIn()
? await getGatewayProvider()
: createProvider(providerConfig);
: await createProvider(providerConfig);
const languageModel = provider.languageModel(model);
await generateText({
model: languageModel,

View file

@ -9,5 +9,9 @@
},
"include": [
"src"
],
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

View file

@ -275,6 +275,39 @@ const ipcSchemas = {
})),
}),
},
'github-copilot:authenticate': {
req: z.null(),
res: z.union([
z.object({
success: z.literal(true),
userCode: z.string(),
verificationUri: z.string(),
expiresIn: z.number(),
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]),
},
'github-copilot:isAuthenticated': {
req: z.null(),
res: z.object({
authenticated: z.boolean(),
}),
},
'github-copilot:disconnect': {
req: z.null(),
res: z.union([
z.object({
success: z.literal(true),
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]),
},
'account:getRowboat': {
req: z.null(),
res: z.object({

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const LlmProvider = z.object({
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]),
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "github-copilot"]),
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),