From 73fc5d173d68811cf79ca2c190e8bcc610f17bc5 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 6 Feb 2026 22:33:48 -0800 Subject: [PATCH] feat: add auto-login for GitHub Copilot when no token is available GitHubModelsClient now lazily resolves tokens at first API call. If no token exists (env var, stored OAuth, or config), it triggers the OAuth device flow automatically via an onLoginRequired callback wired in both the TUI and daemon entry points. --- src/cli/tui.ts | 15 ++++++++++- src/daemon/index.ts | 7 ++++++ src/models/github.ts | 59 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/cli/tui.ts b/src/cli/tui.ts index b630423..8141bac 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -74,7 +74,20 @@ export function registerTuiCommand(program: Command): void { case 'bedrock': return new BedrockClient({ model: cfg.model, region: cfg.endpoint, accessKeyId: cfg.api_key, secretAccessKey: cfg.auth_token }); case 'github': - return new GitHubModelsClient({ model: cfg.model, apiKey: cfg.api_key, endpoint: cfg.endpoint }); + return new GitHubModelsClient({ + model: cfg.model, + apiKey: cfg.api_key, + endpoint: cfg.endpoint, + onLoginRequired: async () => { + const { loginGitHub } = await import('../auth/index.js'); + console.log('\nGitHub authentication required. Starting login flow...'); + return loginGitHub((userCode, verificationUri) => { + console.log(`\nVisit: ${verificationUri}`); + console.log(`Enter code: ${userCode}\n`); + console.log('Waiting for authorization...'); + }); + }, + }); default: throw new Error(`Unknown provider: ${cfg.provider}`); } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index b1a3b53..b594100 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -114,6 +114,13 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { model: cfg.model, apiKey: cfg.api_key, endpoint: cfg.endpoint, + onLoginRequired: async () => { + const { loginGitHub } = await import('../auth/index.js'); + return loginGitHub((userCode, verificationUri) => { + console.log(`GitHub login required. Visit: ${verificationUri}`); + console.log(`Enter code: ${userCode}`); + }); + }, }); default: throw new Error(`Unknown model provider: ${(cfg as Record).provider}`); diff --git a/src/models/github.ts b/src/models/github.ts index 1570704..1c50fac 100644 --- a/src/models/github.ts +++ b/src/models/github.ts @@ -6,7 +6,13 @@ 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) + endpoint?: string; // Override base URL (default: https://api.githubcopilot.com) + /** + * Optional callback invoked when no token is available at API call time. + * Should return a valid token (e.g. by running the OAuth device flow). + * If not provided and no token is available, API calls will fail with auth errors. + */ + onLoginRequired?: () => Promise; } const DEFAULT_ENDPOINT = 'https://api.githubcopilot.com'; @@ -39,14 +45,19 @@ export class GitHubModelsClient implements ModelClient { private client: OpenAI; private model: string; private defaultMaxTokens: number; + private baseURL: string; + private onLoginRequired?: () => Promise; + private tokenResolved = false; constructor(config: GitHubModelsClientConfig) { const apiKey = config.apiKey ?? getGitHubToken() ?? ''; - const baseURL = config.endpoint ?? DEFAULT_ENDPOINT; + this.baseURL = config.endpoint ?? DEFAULT_ENDPOINT; + this.onLoginRequired = config.onLoginRequired; + this.tokenResolved = !!apiKey; this.client = new OpenAI({ - apiKey, - baseURL, + apiKey: apiKey || 'placeholder', + baseURL: this.baseURL, defaultHeaders: { 'X-GitHub-Api-Version': '2022-11-28', 'Openai-Intent': 'conversation-edits', @@ -56,7 +67,46 @@ export class GitHubModelsClient implements ModelClient { this.defaultMaxTokens = config.maxTokens ?? 4096; } + /** + * Ensure we have a valid token before making an API call. + * If no token was resolved at construction time and an onLoginRequired + * callback is provided, invoke it to obtain a token (e.g. via OAuth device flow). + */ + private async ensureToken(): Promise { + if (this.tokenResolved) return; + + // Try resolving again (user might have logged in via /login since construction) + const token = getGitHubToken(); + if (token) { + this.rebuildClient(token); + return; + } + + // Trigger auto-login if callback provided + if (this.onLoginRequired) { + const newToken = await this.onLoginRequired(); + this.rebuildClient(newToken); + return; + } + + // No token and no callback — the API call will fail with an auth error + } + + /** Rebuild the OpenAI client with a new API key. */ + private rebuildClient(apiKey: string): void { + this.client = new OpenAI({ + apiKey, + baseURL: this.baseURL, + defaultHeaders: { + 'X-GitHub-Api-Version': '2022-11-28', + 'Openai-Intent': 'conversation-edits', + }, + }); + this.tokenResolved = true; + } + async chat(request: ChatRequest): Promise { + await this.ensureToken(); const messages: OpenAI.ChatCompletionMessageParam[] = []; if (request.system) { @@ -125,6 +175,7 @@ export class GitHubModelsClient implements ModelClient { } async *chatStream(request: ChatRequest): AsyncIterable { + await this.ensureToken(); const messages: OpenAI.ChatCompletionMessageParam[] = []; if (request.system) {