diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 36aafbd8..c3330c8c 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -854,7 +854,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"; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index f4fe42d6..7b3c17ea 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -748,7 +748,7 @@ export const BuiltinTools: z.infer = { 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.'; diff --git a/apps/x/packages/core/src/auth/github-copilot-auth.ts b/apps/x/packages/core/src/auth/github-copilot-auth.ts new file mode 100644 index 00000000..43465c0c --- /dev/null +++ b/apps/x/packages/core/src/auth/github-copilot-auth.ts @@ -0,0 +1,189 @@ +/** + * 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 { OAuthTokens } from './types.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; +}> { + 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('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('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 { + const oauthRepo = container.resolve('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 { + const oauthRepo = container.resolve('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('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) + */ +export async function disconnectGitHubCopilot(): Promise { + console.log('[GitHub Copilot] Disconnecting...'); + const oauthRepo = container.resolve('oauthRepo'); + await oauthRepo.delete(PROVIDER_NAME); + console.log('[GitHub Copilot] Disconnected successfully'); +} diff --git a/apps/x/packages/core/src/auth/github-copilot-models.ts b/apps/x/packages/core/src/auth/github-copilot-models.ts new file mode 100644 index 00000000..0da39ed0 --- /dev/null +++ b/apps/x/packages/core/src/auth/github-copilot-models.ts @@ -0,0 +1,101 @@ +/** + * GitHub Copilot Model Integration + * + * Handles GitHub Copilot model discovery and LLM provider initialization + */ + +import { getGitHubCopilotAccessToken, isGitHubCopilotAuthenticated } from './github-copilot-auth.js'; +import { ProviderV2 } from '@ai-sdk/provider'; +import { createOpenAI } from '@ai-sdk/openai'; +import z from 'zod'; +import { LlmProvider } from '@x/shared/dist/models.js'; + +// GitHub Copilot API endpoint +const GITHUB_COPILOT_API_BASE = 'https://models.github.com/api/openai/'; + +// List of models available through GitHub Copilot +// Based on GitHub Copilot documentation +export const GITHUB_COPILOT_MODELS = [ + 'gpt-4o', + 'gpt-4-turbo', + 'gpt-4', + 'gpt-3.5-turbo', + 'claude-3.5-sonnet', // If available in student plan + 'claude-3-opus', // If available in student plan +] 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 { + // 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 +): Promise { + 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 access token (will handle refresh if needed) + const accessToken = await getGitHubCopilotAccessToken(); + + // Create OpenAI-compatible provider with GitHub Copilot endpoint + return createOpenAI({ + apiKey: accessToken, + baseURL: config.baseURL || GITHUB_COPILOT_API_BASE, + headers: { + ...config.headers, + 'user-agent': 'Rowboat/1.0', + }, + }); +} + +/** + * 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 getGitHubCopilotAccessToken(); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index 3f7c5ffa..aa17e0f5 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -653,7 +653,7 @@ export async function processRowboatInstruction( export async function classifySchedule(instruction: string): Promise { const repo = container.resolve('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(); diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 30e3c5d4..e7f04443 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -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); diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 87f7c4fc..075dc119 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -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): ProviderV2 { +export async function createProvider(config: z.infer): Promise { const { apiKey, baseURL, headers } = config; switch (config.flavor) { case "openai": @@ -66,16 +67,8 @@ export function createProvider(config: z.infer): ProviderV2 { headers, }) as unknown as ProviderV2; case "github-copilot": - // GitHub Copilot uses OpenAI-compatible API - // baseURL should be set by the auth provider or default to GitHub Copilot endpoint - return createOpenAI({ - apiKey, - baseURL: baseURL || "https://models.github.com/api/openai/", - headers: { - ...headers, - "user-agent": "Rowboat/1.0", - }, - }); + // GitHub Copilot uses Device Flow OAuth for authentication + return await createGitHubCopilotProvider(config); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } @@ -93,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,