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.
This commit is contained in:
William Valentin
2026-02-06 22:33:48 -08:00
parent f363717f5f
commit 73fc5d173d
3 changed files with 76 additions and 5 deletions
+55 -4
View File
@@ -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<string>;
}
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<string>;
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<void> {
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<ChatResponse> {
await this.ensureToken();
const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) {
@@ -125,6 +175,7 @@ export class GitHubModelsClient implements ModelClient {
}
async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
await this.ensureToken();
const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) {